From 744bfaaa451acda21789ed6cc0cc0f3a5ddb86d5 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Thu, 27 Aug 2026 13:55:13 +0100 Subject: [PATCH 01/10] Implement profiles to replace players, introducing a persistent profile system for shot attribution. The new `ProfileStore` manages profiles with stable IDs, allowing for renaming without data loss. Updated socket events and UI components reflect this change, while legacy player references have been removed. Tests for the profile functionality have been added to ensure reliability. --- docs/CHANGELOG.md | 10 +- docs/superpowers/plans/2026-08-27-profiles.md | 2633 +++++++++++++++++ .../specs/2026-08-27-profiles-design.md | 290 ++ src/openflight/launch_monitor.py | 3 +- src/openflight/profiles.py | 227 ++ src/openflight/server.py | 134 +- src/openflight/session_logger.py | 6 +- src/openflight/swing_speed.py | 3 +- tests/test_profiles.py | 315 ++ tests/test_server.py | 407 ++- ui/README.md | 43 +- ui/mock-server/handlers.ts | 42 +- ui/mock-server/session.ts | 73 +- ui/mock-server/shotGenerator.ts | 6 +- ui/src/App.test.tsx | 8 +- ui/src/App.tsx | 149 +- .../components/panel/AddPlayerDialog.test.tsx | 31 - ui/src/components/panel/AddPlayerDialog.tsx | 51 - .../panel/ClearSessionDialog.test.tsx | 12 +- .../components/panel/ClearSessionDialog.tsx | 18 +- ui/src/components/panel/LivePanel.test.tsx | 33 +- ui/src/components/panel/LivePanel.tsx | 22 +- ui/src/components/panel/MenuSheet.test.tsx | 8 +- ui/src/components/panel/MenuSheet.tsx | 2 +- ui/src/components/panel/PanelFooter.test.tsx | 6 +- ui/src/components/panel/PanelFooter.tsx | 2 +- ui/src/components/panel/PanelHeader.tsx | 4 +- ui/src/components/panel/PlayersPanel.test.tsx | 110 - ui/src/components/panel/PlayersPanel.tsx | 84 - .../panel/ProfileNameDialog.test.tsx | 59 + ui/src/components/panel/ProfileNameDialog.tsx | 54 + .../components/panel/ProfilesPanel.test.tsx | 83 + ui/src/components/panel/ProfilesPanel.tsx | 106 + ui/src/components/panel/ShotsPanel.test.tsx | 19 +- ui/src/components/panel/ShotsPanel.tsx | 41 +- ui/src/components/panel/StatsPanel.test.tsx | 21 +- ui/src/components/panel/StatsPanel.tsx | 35 +- ui/src/components/panel/index.ts | 4 +- ui/src/components/panel/liveMetrics.ts | 2 +- ui/src/components/panel/panel.css | 82 +- ui/src/components/panel/views.ts | 4 +- ui/src/i18n/en.ts | 23 +- ui/src/i18n/es.ts | 23 +- ui/src/i18n/fr.ts | 23 +- ui/src/i18n/i18n.test.ts | 4 +- ui/src/i18n/pt.ts | 23 +- ui/src/services/playerSocketSync.test.ts | 46 - ui/src/services/playerSocketSync.ts | 18 - ui/src/services/sessionClear.test.ts | 18 +- ui/src/services/sessionClear.ts | 10 +- ui/src/services/sessionClubSync.ts | 4 +- ui/src/services/socketService.ts | 36 +- ui/src/stores/useHeroMetricStore.ts | 2 +- ui/src/stores/usePlayerStore.test.ts | 47 - ui/src/stores/usePlayerStore.ts | 95 - ui/src/stores/useProfileStore.test.ts | 61 + ui/src/stores/useProfileStore.ts | 32 + ui/src/stores/useSystemStore.ts | 4 - ui/src/types/profile.ts | 14 + ui/src/types/shot.test.ts | 88 +- ui/src/types/shot.ts | 23 +- ui/src/types/socket.ts | 3 +- ui/src/utils/validationCsv.ts | 4 +- ui/tests/e2e/app.spec.ts | 103 +- ui/tests/e2e/helpers.ts | 51 +- 65 files changed, 4956 insertions(+), 1041 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-27-profiles.md create mode 100644 docs/superpowers/specs/2026-08-27-profiles-design.md create mode 100644 src/openflight/profiles.py create mode 100644 tests/test_profiles.py delete mode 100644 ui/src/components/panel/AddPlayerDialog.test.tsx delete mode 100644 ui/src/components/panel/AddPlayerDialog.tsx delete mode 100644 ui/src/components/panel/PlayersPanel.test.tsx delete mode 100644 ui/src/components/panel/PlayersPanel.tsx create mode 100644 ui/src/components/panel/ProfileNameDialog.test.tsx create mode 100644 ui/src/components/panel/ProfileNameDialog.tsx create mode 100644 ui/src/components/panel/ProfilesPanel.test.tsx create mode 100644 ui/src/components/panel/ProfilesPanel.tsx delete mode 100644 ui/src/services/playerSocketSync.test.ts delete mode 100644 ui/src/services/playerSocketSync.ts delete mode 100644 ui/src/stores/usePlayerStore.test.ts delete mode 100644 ui/src/stores/usePlayerStore.ts create mode 100644 ui/src/stores/useProfileStore.test.ts create mode 100644 ui/src/stores/useProfileStore.ts create mode 100644 ui/src/types/profile.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aa528d58a..8e0bd3206 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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`. Profiles can be renamed without orphaning + their shots. 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 @@ -16,7 +24,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/docs/superpowers/plans/2026-08-27-profiles.md b/docs/superpowers/plans/2026-08-27-profiles.md new file mode 100644 index 000000000..4953d0b64 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-profiles.md @@ -0,0 +1,2633 @@ +# Profiles (replacing Players) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the browser-local, name-keyed "player" concept with server-owned "profiles" that have stable ids, so shots survive renames and later features can attach settings to a profile. + +**Architecture:** A new `ProfileStore` owns `~/.config/openflight/profiles.json` and becomes the single source of truth for the roster and the active selection. The socket exposes one authoritative `profiles` snapshot event (emitted after every mutation, including rejected ones) plus five client→server mutations. Shots are stamped with `profile_id` (the exact-match filter key) and `profile_name` (a denormalized snapshot for readable logs). The UI store becomes a thin mirror with no `localStorage`, which deletes the two-sources-of-truth reconciliation code in `App.tsx`. + +**Tech Stack:** Python 3 / Flask-SocketIO / pytest / pylint / ruff (backend); React + TypeScript / Zustand / socket.io-client / vitest / Playwright (frontend). All Python commands run through `uv run`. + +**Spec:** `docs/superpowers/specs/2026-08-27-profiles-design.md` + +## Global Constraints + +- **Never commit without explicit direction from the repo owner.** Each task ends by staging files and reporting; do not run `git commit` unless asked. (This overrides the usual commit-per-task habit.) +- **Always use `uv` for Python.** `uv run pytest`, `uv run pylint`, `uv run ruff`. Never bare `python`/`pip`/`pytest`. +- **Test-first.** Every task writes the failing test, runs it to confirm it fails for the right reason, then implements. +- **Do not touch `src/openflight/sim/` or `src/openflight/gspro/`.** Their `PlayerState` / `Player` fields are an external wire protocol (GSPro), not our terminology. `session_logger.log_sim_player` also stays as-is. +- **Clean break.** No migration of existing player data, no dual-emit compatibility, no reading of old `localStorage` keys. +- **Profile record shape:** `{"id": , "name": , "created_at": , "settings": {}}`. +- **Store file:** `~/.config/openflight/profiles.json`, contents `{"profiles": [...], "active_profile_id": "..."}`. +- **Limits:** max 12 profiles, name trimmed and capped at 40 characters, default seeded profile is named `Profile 1`. +- **Filtering is exact-match on `profile_id`.** No case folding anywhere. `profile_name` is never used for filtering. +- **Lint gates:** `uv run pylint src/openflight/ --fail-under=9`, `uv run ruff check src/openflight/`, `uv run ruff format --check src/openflight/`, `cd ui && npm run lint`. + +--- + +## File Structure + +**Created** + +| File | Responsibility | +|---|---| +| `src/openflight/profiles.py` | `Profile` dataclass + `ProfileStore`: load, validate, mutate, atomically persist the roster. No Flask, no socket knowledge. | +| `tests/test_profiles.py` | Unit tests for `ProfileStore` in isolation, using `tmp_path`. | +| `ui/src/stores/useProfileStore.ts` | Zustand mirror of the server snapshot. No persistence, no business logic. | +| `ui/src/stores/useProfileStore.test.ts` | Tests for the mirror. | +| `ui/src/components/panel/ProfilesPanel.tsx` | Roster panel: select, rename, remove. | +| `ui/src/components/panel/ProfilesPanel.test.tsx` | Tests for the panel. | +| `ui/src/components/panel/ProfileNameDialog.tsx` | One dialog serving both add and rename (DRY — they differ only in title, button label, and initial value). | +| `ui/src/components/panel/ProfileNameDialog.test.tsx` | Tests for both modes. | + +**Deleted** + +| File | Why | +|---|---| +| `ui/src/services/playerSocketSync.ts` | Existed only to referee the `session_state` vs `player_changed` race. One snapshot event removes the race. | +| `ui/src/services/playerSocketSync.test.ts` | Ditto. | +| `ui/src/stores/usePlayerStore.ts` + `.test.ts` | Replaced by `useProfileStore.ts`. | +| `ui/src/components/panel/PlayersPanel.tsx` + `.test.tsx` | Replaced by `ProfilesPanel`. | +| `ui/src/components/panel/AddPlayerDialog.tsx` + `.test.tsx` | Replaced by `ProfileNameDialog`. | + +**Modified** + +`src/openflight/launch_monitor.py`, `src/openflight/swing_speed.py`, `src/openflight/session_logger.py`, `src/openflight/server.py`, `tests/test_server.py`, `ui/src/types/shot.ts` (+ test), `ui/src/services/sessionClear.ts` (+ test), `ui/src/services/socketService.ts`, `ui/src/stores/useSystemStore.ts`, `ui/src/App.tsx` (+ test), `ui/src/components/panel/index.ts`, `ui/src/components/panel/views.ts`, `ui/src/components/panel/panel.css`, `ui/src/components/panel/LivePanel.tsx` (+ test), `ui/src/components/panel/ShotsPanel.tsx` (+ test), `ui/src/components/panel/StatsPanel.tsx` (+ test), `ui/src/components/panel/PanelHeader.tsx`, `ui/src/components/panel/PanelFooter.tsx` (+ test), `ui/src/components/panel/MenuSheet.tsx` (+ test), `ui/src/components/panel/ClearSessionDialog.tsx` (+ test), `ui/src/components/panel/liveMetrics.ts`, `ui/src/i18n/{en,es,fr,pt}.ts`, `ui/mock-server/{handlers,session,shotGenerator}.ts`, `ui/tests/e2e/{app.spec.ts,helpers.ts}`, `ui/README.md`. + +--- + +## Task 1: ProfileStore + +The persistence layer, with no Flask or socket dependency so it can be tested directly. + +**Files:** +- Create: `src/openflight/profiles.py` +- Test: `tests/test_profiles.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `Profile` dataclass: `id: str`, `name: str`, `created_at: str`, `settings: dict`, method `to_dict() -> dict`. + - `ProfileStore(path: str | Path | None = None)` with: + `list() -> list[Profile]`, `get_active() -> Profile`, `snapshot() -> dict`, + `add(name: str) -> Profile | None`, `rename(profile_id: str, name: str) -> bool`, + `remove(profile_id: str) -> bool`, `set_active(profile_id: str) -> bool`. + - Module constants: `DEFAULT_PROFILES_PATH`, `DEFAULT_PROFILE_NAME = "Profile 1"`, `MAX_PROFILES = 12`, `MAX_NAME_LENGTH = 40`. + - `snapshot()` returns `{"profiles": [, ...], "active_profile_id": str}`. + - All mutators return falsy (`None` / `False`) on rejection and leave state untouched. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_profiles.py`: + +```python +"""Tests for the persistent profile roster.""" + +import json + +import pytest + +from openflight.profiles import ( + DEFAULT_PROFILE_NAME, + MAX_PROFILES, + ProfileStore, +) + + +@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} + + +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] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_profiles.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'openflight.profiles'` + +- [ ] **Step 3: Implement the store** + +Create `src/openflight/profiles.py`: + +```python +"""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" +DEFAULT_PROFILE_NAME = "Profile 1" +MAX_PROFILES = 12 +MAX_NAME_LENGTH = 40 + + +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.""" + return { + "id": self.id, + "name": self.name, + "created_at": self.created_at, + "settings": 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 = Path(path).expanduser() if path else DEFAULT_PROFILES_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]: + """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.""" + return { + "profiles": [profile.to_dict() for profile in self._profiles], + "active_profile_id": self.get_active().id, + } + + # -- 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.""" + payload = { + "profiles": [profile.to_dict() for profile in self._profiles], + "active_profile_id": self.get_active().id, + } + 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.warning("[profiles] could not save %s: %s", self._path, error) + try: + temp_path.unlink() + except OSError: + pass + + 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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_profiles.py -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Lint** + +Run: +```bash +uv run pylint src/openflight/profiles.py --fail-under=9 +uv run ruff check src/openflight/profiles.py +uv run ruff format --check src/openflight/profiles.py +``` +Expected: clean. `pylint` will flag `list` shadowing a builtin as `W0622` only for arguments, not methods — if it complains about the method name, add `# pylint: disable=redefined-builtin` on the method, not a rename: `list()` is the right name for the reader. + +- [ ] **Step 6: Stage (do not commit)** + +```bash +git add src/openflight/profiles.py tests/test_profiles.py +``` + +--- + +## Task 2: Stamp shots with profile_id and profile_name + +Rename the attribution fields on the two event dataclasses and the session logger. The server still passes `current_player_name` at this point — Task 3 rewires it. Keeping this task separate means a reviewer can check the data-shape change without the socket rewrite tangled into it. + +**Files:** +- Modify: `src/openflight/launch_monitor.py:290` +- Modify: `src/openflight/swing_speed.py:29` +- Modify: `src/openflight/session_logger.py:383,433` +- Modify: `src/openflight/server.py` (`shot_to_dict`, `swing_speed_to_dict`, `swing_speed_to_shot_dict`, and the `log_shot` call around line 3636) +- Test: `tests/test_server.py` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: + - `Shot.profile_id: str = ""` and `Shot.profile_name: str = ""` (replacing `player_name`). + - `SwingSpeedEvent.profile_id: str = ""` and `SwingSpeedEvent.profile_name: str = ""`. + - `SessionLogger.log_shot(..., profile_id: Optional[str] = None, profile_name: Optional[str] = None, ...)` writing both keys into the JSONL entry. + - `shot_to_dict` / `swing_speed_to_dict` / `swing_speed_to_shot_dict` emit `"profile_id"` and `"profile_name"` in place of `"player_name"`. + +Defaults are empty strings, not `"Player 1"`: an unstamped shot belongs to no profile and must fall out of every filter. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_server.py` (near the existing `swing_speed_to_shot_dict` tests): + +```python +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, + smash_factor=1.4, + estimated_carry_yards=250, + estimated_carry_range=(245, 255), + 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, + smash_factor=1.4, + estimated_carry_yards=250, + estimated_carry_range=(245, 255), + 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 +``` + +Note: check the existing `Shot(...)` construction in `tests/test_server.py` and copy its exact required arguments — the fields above are illustrative of the shape, and the dataclass may require more or fewer positional values. Match what the file already does rather than inventing a constructor call. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_server.py::TestProfileStamping -v` +Expected: FAIL — `AttributeError` / `KeyError: 'profile_id'` + +- [ ] **Step 3: Rename the dataclass fields** + +In `src/openflight/launch_monitor.py`, replace line 290: + +```python + player_name: str = "Player 1" +``` + +with: + +```python + profile_id: str = "" + profile_name: str = "" +``` + +In `src/openflight/swing_speed.py`, replace line 29 the same way: + +```python + profile_id: str = "" + profile_name: str = "" +``` + +- [ ] **Step 4: Update the session logger** + +In `src/openflight/session_logger.py`, in `log_shot`, replace the `player_name: Optional[str] = None,` parameter (line 383) with: + +```python + profile_id: Optional[str] = None, + profile_name: Optional[str] = None, +``` + +and replace the `"player_name": player_name,` entry (line 433) with: + +```python + "profile_id": profile_id, + "profile_name": profile_name, +``` + +Leave `log_sim_player` untouched — it logs a simulator-protocol event, not our profile. + +- [ ] **Step 5: Update the server payload builders** + +In `src/openflight/server.py`: + +- In `shot_to_dict`, replace `"player_name": shot.player_name,` with: + ```python + "profile_id": shot.profile_id, + "profile_name": shot.profile_name, + ``` +- In `swing_speed_to_dict`, replace `"player_name": event.player_name,` with: + ```python + "profile_id": event.profile_id, + "profile_name": event.profile_name, + ``` +- In `swing_speed_to_shot_dict`, make the same replacement. +- In the `log_shot(...)` call (around line 3636), replace `player_name=shot.player_name,` with: + ```python + profile_id=shot.profile_id, + profile_name=shot.profile_name, + ``` +- In `on_shot_detected`, temporarily replace `shot.player_name = current_player_name` with `shot.profile_name = current_player_name`, and in `on_swing_speed_detected` replace `event.player_name = current_player_name` with `event.profile_name = current_player_name`. These two lines are placeholders that Task 3 replaces with the real store lookup; they exist only so the module imports and the suite runs green between tasks. + +- [ ] **Step 6: Fix the existing tests that assert on player_name** + +Run `uv run pytest tests/test_server.py -v` and update every failing assertion that reads `player_name` from a payload or sets `shot.player_name` to use `profile_name` instead. Do **not** yet touch `TestHandleClearSession` — Task 3 rewrites that class wholesale. + +- [ ] **Step 7: Run the full Python suite** + +Run: `uv run pytest tests/ -v` +Expected: PASS + +- [ ] **Step 8: Stage (do not commit)** + +```bash +git add src/openflight/launch_monitor.py src/openflight/swing_speed.py \ + src/openflight/session_logger.py src/openflight/server.py tests/test_server.py +``` + +--- + +## Task 3: Server profile state and socket handlers + +Replace the `current_player_name` global with the store, swap `set_player` for the five profile mutations, and make every mutation broadcast the authoritative snapshot. + +**Files:** +- Modify: `src/openflight/server.py:90` (the global), `:1964` (`_session_state_payload`), `:2079-2088` (`handle_connect`), `:2116-2124` (`handle_set_player`), `:2144-2200` (normalize/match/clear helpers and `handle_clear_session`), `:3125` and `:3782` (stamping) +- Test: `tests/test_server.py` + +**Interfaces:** +- Consumes: `ProfileStore`, `Profile` from Task 1; `Shot.profile_id` / `.profile_name` from Task 2. +- Produces: + - `server_module.profile_store: Optional[ProfileStore]` — module global, lazily created. + - `get_profile_store() -> ProfileStore`. + - `_emit_profiles() -> None` — broadcasts `("profiles", snapshot)`. + - Socket handlers: `handle_get_profiles()`, `handle_set_active_profile(data)`, `handle_add_profile(data)`, `handle_rename_profile(data)`, `handle_remove_profile(data)`, and a rewritten `handle_clear_session(data=None)`. + - `_clear_profile_rows(profile_id: str) -> None`. + - `current_player_name`, `_normalize_player_name`, `_player_matches`, `_clear_player_rows`, and `handle_set_player` no longer exist. + +The store is created lazily, not at import, so importing the server in a test never writes to the real `~/.config`. Tests set `server_module.profile_store` to a `tmp_path`-backed store. + +- [ ] **Step 1: Write the failing tests** + +Replace the whole `TestHandleClearSession` class in `tests/test_server.py` and add the new classes: + +```python +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_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 + + +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 profile's rows, matched by id.""" + + @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 + + 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_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.socketio, "emit", lambda *args, **kwargs: emitted.append(args) + ) + + server_module.handle_clear_session({"profile_id": james.id}) + + 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["profile_id"] == james.id + assert [entry["profile_name"] for entry in payload["shots"]] == ["Alex"] + + 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.profile_id = alex.id + second = monitor.simulate_shot() + second.profile_id = james.id + + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) + + server_module.handle_clear_session() + + assert [shot.profile_id for shot in monitor.get_shots()] == [james.id] + + 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() + 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.socketio, "emit", lambda *args, **kwargs: None) + + server_module.handle_clear_session({"profile_id": lower.id}) + + assert [shot.profile_name for shot in monitor.get_shots()] == ["James"] + + def test_unstamped_shots_belong_to_no_profile(self, store, monkeypatch): + active = store.get_active() + monitor = MockLaunchMonitor() + monitor.connect() + monitor.start() + monitor.simulate_shot() + + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) + + server_module.handle_clear_session({"profile_id": active.id}) + + assert len(monitor.get_shots()) == 1 + + 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 + + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) + + 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 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_server.py -k "Profile or ClearSession or SessionState" -v` +Expected: FAIL — `AttributeError: module 'openflight.server' has no attribute 'handle_get_profiles'` + +- [ ] **Step 3: Replace the global with the store** + +In `src/openflight/server.py`, add to the imports: + +```python +from .profiles import ProfileStore +``` + +Replace line 90: + +```python +current_player_name: str = "Player 1" +``` + +with: + +```python +# 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 +``` + +(`Optional` is already imported in this module; confirm before adding it again.) + +- [ ] **Step 4: Replace the socket handlers** + +Replace `handle_set_player` (lines 2116-2124) with: + +```python +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.""" + profile_id = data.get("profile_id") if isinstance(data, dict) else None + get_profile_store().set_active(profile_id) + _emit_profiles() + + +@socketio.on("add_profile") +def handle_add_profile(data=None): + """Add a profile and make it active.""" + name = data.get("name") if isinstance(data, dict) else None + get_profile_store().add(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 = data if isinstance(data, dict) else {} + get_profile_store().rename(payload.get("profile_id"), payload.get("name")) + _emit_profiles() + + +@socketio.on("remove_profile") +def handle_remove_profile(data=None): + """Delete a profile. Refused for the active or the last one.""" + profile_id = data.get("profile_id") if isinstance(data, dict) else None + get_profile_store().remove(profile_id) + _emit_profiles() +``` + +- [ ] **Step 5: Replace the clear-session helpers** + +Replace `_normalize_player_name`, `_player_matches`, `_clear_player_rows`, and `handle_clear_session` (lines 2144-2200) with: + +```python +def _clear_profile_rows(profile_id: str) -> None: + """Remove one profile'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 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 getattr(event, "profile_id", "") != profile_id + ] + return + + shots = getattr(monitor, "_shots", None) + if shots is not None: + 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 + + if hasattr(monitor, "clear_session"): + monitor.clear_session() + + +@socketio.on("clear_session") +def handle_clear_session(data=None): + """Clear recorded rows for one profile only.""" + raw_id = data.get("profile_id") if isinstance(data, dict) else None + 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", + {"profile_id": profile_id, "shots": _session_shots()}, + ) +``` + +Note the deliberate behaviour change in `_clear_profile_rows`: shots with an empty `profile_id` are never cleared, because they belong to no profile. + +- [ ] **Step 6: Rewire stamping, session_state, and connect** + +- In `_session_state_payload` (line ~1964), delete the `"player_name": current_player_name,` entry entirely. Add nothing in its place. +- In `handle_connect` (line ~2080), add `_emit_profiles()` immediately after `_emit_sim_snapshot()` — outside the `if monitor:` block, so the roster arrives even when no monitor is running. +- In `on_shot_detected` (line ~3125), replace the Task 2 placeholder with: + ```python + active_profile = get_profile_store().get_active() + shot.profile_id = active_profile.id + shot.profile_name = active_profile.name + ``` +- In `on_swing_speed_detected` (line ~3782), replace the placeholder with: + ```python + active_profile = get_profile_store().get_active() + event.profile_id = active_profile.id + event.profile_name = active_profile.name + ``` + +- [ ] **Step 7: Run the tests** + +Run: `uv run pytest tests/test_server.py -v` +Expected: PASS. If any remaining test references `current_player_name` or `handle_set_player`, rewrite it against the store — those names are gone by design. + +- [ ] **Step 8: Confirm no references survive** + +Run: `grep -rn "player_name\|current_player_name\|handle_set_player" src/openflight/ --include=*.py | grep -v "sim/\|gspro/"` +Expected: no output. (`sim/` and `gspro/` hits are correct and must remain.) + +- [ ] **Step 9: Full Python suite and lint** + +Run: +```bash +uv run pytest tests/ -v +uv run pylint src/openflight/ --fail-under=9 +uv run ruff check src/openflight/ && uv run ruff format --check src/openflight/ +``` +Expected: PASS, score ≥ 9.0, clean. + +- [ ] **Step 10: Stage (do not commit)** + +```bash +git add src/openflight/server.py tests/test_server.py +``` + +--- + +## Task 4: UI types and pure filter helpers + +Pure functions first, with no React or socket involvement, so the filtering semantics are locked down before anything consumes them. + +**Files:** +- Modify: `ui/src/types/shot.ts:20,144,157-169,176` +- Modify: `ui/src/types/socket.ts:35` +- Modify: `ui/src/services/sessionClear.ts` +- Test: `ui/src/types/shot.test.ts`, `ui/src/services/sessionClear.test.ts` +- Create: `ui/src/types/profile.ts` + +**Interfaces:** +- Consumes: the server payload shape from Tasks 2-3. +- Produces: + - `ui/src/types/profile.ts`: `export interface Profile { id: string; name: string; created_at: string; settings: Record; }` and `export interface ProfilesSnapshot { profiles: Profile[]; active_profile_id: string; }`. + - `Shot.profile_id?: string` and `Shot.profile_name?: string` (replacing `player_name?`). + - `SwingSpeedEvent.profile_id?: string`, `SwingSpeedEvent.profile_name?: string`. + - `filterShotsByProfile(shots: Shot[], profileId: string): Shot[]` + - `excludeShotsByProfile(shots: Shot[], profileId: string): Shot[]` + - `SwingSpeedStatsFilter.profileId?: string | null` (replacing `playerName`). + - `SessionClearedPayload { profile_id?: string; shots?: Shot[] }`. + +- [ ] **Step 1: Write the failing tests** + +Add to `ui/src/types/shot.test.ts`: + +```ts +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('matches exactly, without folding case', () => { + const shots = [shotWith('AAA'), shotWith('aaa')]; + + expect(filterShotsByProfile(shots, 'aaa')).toEqual([shots[1]]); + }); + + it('excludes unstamped shots from every profile', () => { + const shots = [shotWith(undefined), shotWith('')]; + + expect(filterShotsByProfile(shots, 'aaa')).toEqual([]); + }); + + it('returns nothing for a blank profile id', () => { + const shots = [shotWith('aaa'), shotWith(undefined)]; + + expect(filterShotsByProfile(shots, '')).toEqual([]); + }); +}); + +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]]); + }); +}); +``` + +Add to `ui/src/services/sessionClear.test.ts`: + +```ts +it('prefers the server shot list', () => { + const server = [{ profile_id: 'bbb' } as Shot]; + + expect(remainingShotsAfterClear([{ profile_id: 'aaa' } as Shot], { shots: server })).toEqual(server); +}); + +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]]); +}); +``` + +Also update the existing tests in both files that reference `player_name` / `filterShotsByPlayer` / `filterSwingSpeedShots({ playerName })` to the new names, and add the new imports. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ui && npx vitest run src/types/shot.test.ts src/services/sessionClear.test.ts` +Expected: FAIL — `filterShotsByProfile is not a function` + +- [ ] **Step 3: Create the profile types** + +Create `ui/src/types/profile.ts`: + +```ts +/** 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; +} +``` + +- [ ] **Step 4: Update the shot types and filters** + +In `ui/src/types/shot.ts`: + +- Replace `player_name?: string;` (line 20) with: + ```ts + profile_id?: string; + profile_name?: string; + ``` +- Replace `playerName?: string | null;` in `SwingSpeedStatsFilter` (line 144) with `profileId?: string | null;` +- Delete `normalizePlayerName` (lines 157-159) entirely — exact id matching needs no normalizer. +- Replace `filterShotsByPlayer` / `excludeShotsByPlayer` (lines 161-169) with: + ```ts + export function filterShotsByProfile(shots: Shot[], profileId: string): Shot[] { + if (!profileId) return []; + return shots.filter((shot) => shot.profile_id === profileId); + } + + export function excludeShotsByProfile(shots: Shot[], profileId: string): Shot[] { + if (!profileId) return shots; + return shots.filter((shot) => shot.profile_id !== profileId); + } + ``` +- In `filterSwingSpeedShots` (line 176), replace the scoping line with: + ```ts + const scoped = filter.profileId ? filterShotsByProfile(shots, filter.profileId) : shots; + ``` + +In `ui/src/types/socket.ts`, replace `player_name?: string;` in `SwingSpeedEvent` (line 35) with: + +```ts + profile_id?: string; + profile_name?: string; +``` + +- [ ] **Step 5: Update sessionClear** + +Replace the body of `ui/src/services/sessionClear.ts`: + +```ts +import type { Shot } from '../types/shot'; +import { excludeShotsByProfile } from '../types/shot'; + +export interface SessionClearedPayload { + profile_id?: string; + shots?: Shot[]; +} + +/** 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?.profile_id) { + return excludeShotsByProfile(currentShots, payload.profile_id); + } + return []; +} +``` + +- [ ] **Step 6: Run the tests** + +Run: `cd ui && npx vitest run src/types/shot.test.ts src/services/sessionClear.test.ts` +Expected: PASS + +- [ ] **Step 7: Stage (do not commit)** + +```bash +git add ui/src/types/profile.ts ui/src/types/shot.ts ui/src/types/shot.test.ts \ + ui/src/types/socket.ts ui/src/services/sessionClear.ts ui/src/services/sessionClear.test.ts +``` + +The project won't typecheck until Task 7 — consumers still call the old names. That's expected; don't chase it here. + +--- + +## Task 5: Profile store mirror and socket wiring + +The store stops being a source of truth. It mirrors the server snapshot and emits mutations. + +**Files:** +- Create: `ui/src/stores/useProfileStore.ts`, `ui/src/stores/useProfileStore.test.ts` +- Delete: `ui/src/stores/usePlayerStore.ts`, `ui/src/stores/usePlayerStore.test.ts`, `ui/src/services/playerSocketSync.ts`, `ui/src/services/playerSocketSync.test.ts` +- Modify: `ui/src/services/socketService.ts`, `ui/src/stores/useSystemStore.ts:14,23,51` + +**Interfaces:** +- Consumes: `Profile`, `ProfilesSnapshot` from Task 4. +- Produces: + - `useProfileStore` with state `profiles: Profile[]`, `activeProfileId: string`, `loaded: boolean`, and action `applySnapshot(snapshot: ProfilesSnapshot): void`. + - Selector helper `export function getActiveProfile(): Profile | null`. + - `socketService.setActiveProfile(profileId: string)`, `.addProfile(name: string)`, `.renameProfile(profileId: string, name: string)`, `.removeProfile(profileId: string)`, `.clearSession(profileId: string)`. + - `useSystemStore.serverPlayerName` / `setServerPlayerName` are removed. + +`loaded` is what the UI gates its skeleton on: false until the first `profiles` event arrives. + +- [ ] **Step 1: Write the failing tests** + +Create `ui/src/stores/useProfileStore.test.ts`: + +```ts +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']); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ui && npx vitest run src/stores/useProfileStore.test.ts` +Expected: FAIL — cannot resolve `./useProfileStore` + +- [ ] **Step 3: Create the store** + +Create `ui/src/stores/useProfileStore.ts`: + +```ts +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, + }); + }, +})); + +/** The active profile record, or null before the first snapshot. */ +export function getActiveProfile(): Profile | null { + const { profiles, activeProfileId } = useProfileStore.getState(); + return profiles.find((profile) => profile.id === activeProfileId) ?? null; +} +``` + +- [ ] **Step 4: Run the store tests** + +Run: `cd ui && npx vitest run src/stores/useProfileStore.test.ts` +Expected: PASS + +- [ ] **Step 5: Wire the socket service** + +In `ui/src/services/socketService.ts`: + +- Delete the `import { ingestSocketPlayerName } from './playerSocketSync';` line. +- Add `import { useProfileStore } from '../stores/useProfileStore';` and `import type { ProfilesSnapshot } from '../types/profile';` +- In the `connect` handler, add `this.socket?.emit('get_profiles');` alongside the other `get_*` emits. +- Replace the `player_changed` listener (lines 106-108) with: + ```ts + this.socket.on('profiles', (data: ProfilesSnapshot) => { + useProfileStore.getState().applySnapshot(data); + }); + ``` +- In the `session_state` listener, delete `player_name?: string;` from the inline payload type and delete the `ingestSocketPlayerName('session_state', data.player_name);` line. +- Replace the `session_cleared` listener signature with `(data?: { profile_id?: string; shots?: Shot[] })` — the body is unchanged. +- Replace `clearSession(playerName: string)` and `setPlayer(playerName: string)` with: + ```ts + 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 }); + } + ``` + +- [ ] **Step 6: Strip the system store** + +In `ui/src/stores/useSystemStore.ts`, delete `serverPlayerName: string | null;` (line 14), `setServerPlayerName` from the interface (line 23), the `serverPlayerName: null,` initial value, and the `setServerPlayerName` implementation (line 51). Nothing replaces them — the profile store holds this now. + +- [ ] **Step 7: Delete the superseded files** + +```bash +git rm ui/src/stores/usePlayerStore.ts ui/src/stores/usePlayerStore.test.ts \ + ui/src/services/playerSocketSync.ts ui/src/services/playerSocketSync.test.ts +``` + +- [ ] **Step 8: Stage (do not commit)** + +```bash +git add ui/src/stores/useProfileStore.ts ui/src/stores/useProfileStore.test.ts \ + ui/src/services/socketService.ts ui/src/stores/useSystemStore.ts +``` + +--- + +## Task 6: Profiles panel and name dialog + +**Files:** +- Create: `ui/src/components/panel/ProfilesPanel.tsx`, `ui/src/components/panel/ProfilesPanel.test.tsx`, `ui/src/components/panel/ProfileNameDialog.tsx`, `ui/src/components/panel/ProfileNameDialog.test.tsx` +- Delete: `ui/src/components/panel/PlayersPanel.tsx` (+ `.test.tsx`), `ui/src/components/panel/AddPlayerDialog.tsx` (+ `.test.tsx`) +- Modify: `ui/src/components/panel/index.ts`, `ui/src/components/panel/views.ts`, `ui/src/components/panel/panel.css` + +**Interfaces:** +- Consumes: `Profile` (Task 4), the i18n keys added in Task 8 (write the code against them now; Task 8 defines them). +- Produces: + - `ProfilesPanel` props: `{ profiles: Profile[]; activeProfileId: string; shots: Shot[]; loaded: boolean; onSelectProfile: (id: string) => void; onRenameProfile: (profile: Profile) => void; onRemoveProfile: (id: string) => void; headerAction?: ReactNode }` + - `ProfileNameDialog` props: `{ mode: 'add' | 'rename'; name: string; onChange: (name: string) => void; onConfirm: () => void; onCancel: () => void }` + - `PanelView` gains `'profiles'` and loses `'players'`. + +One dialog serves both modes because they differ only in title, confirm label, and initial value — two components would be duplication. + +- [ ] **Step 1: Write the failing tests** + +Create `ui/src/components/panel/ProfilesPanel.test.tsx`: + +```tsx +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ProfilesPanel } from './ProfilesPanel'; +import type { Profile } from '../../types/profile'; +import type { Shot } from '../../types/shot'; + +const profile = (id: string, name: string): Profile => ({ + id, + name, + created_at: '2026-08-27T10:00:00Z', + settings: {}, +}); + +const shot = (profileId: string): Shot => ({ profile_id: profileId, ball_speed_mph: 100 }) as Shot; + +const baseProps = { + profiles: [profile('aaa', 'Home'), profile('bbb', 'Range')], + activeProfileId: 'aaa', + shots: [shot('aaa'), shot('aaa'), shot('bbb')], + loaded: true, + onSelectProfile: vi.fn(), + onRenameProfile: vi.fn(), + onRemoveProfile: vi.fn(), +}; + +describe('ProfilesPanel', () => { + it('renders every profile with its shot count', () => { + render(); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('2 shots')).toBeInTheDocument(); + expect(screen.getByText('1 shot')).toBeInTheDocument(); + }); + + it('selects a profile by id, not by name', () => { + const onSelectProfile = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Range')); + + expect(onSelectProfile).toHaveBeenCalledWith('bbb'); + }); + + it('hides remove on the active profile', () => { + render(); + + expect(screen.queryByLabelText('Remove Home')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Remove Range')).toBeInTheDocument(); + }); + + it('hides remove entirely when only one profile exists', () => { + render(); + + expect(screen.queryByLabelText('Remove Home')).not.toBeInTheDocument(); + }); + + it('offers rename for every profile, including the active one', () => { + const onRenameProfile = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText('Rename Home')); + + expect(onRenameProfile).toHaveBeenCalledWith(baseProps.profiles[0]); + }); + + it('shows a skeleton until the roster arrives', () => { + render(); + + expect(screen.getByRole('region', { name: 'Profiles' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByText('Home')).not.toBeInTheDocument(); + }); +}); +``` + +Create `ui/src/components/panel/ProfileNameDialog.test.tsx`: + +```tsx +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ProfileNameDialog } from './ProfileNameDialog'; + +const baseProps = { + mode: 'add' as const, + name: '', + onChange: vi.fn(), + onConfirm: vi.fn(), + onCancel: vi.fn(), +}; + +describe('ProfileNameDialog', () => { + it('titles itself for the add mode', () => { + render(); + + expect(screen.getByRole('dialog', { name: 'Add profile' })).toBeInTheDocument(); + }); + + it('titles itself for the rename mode', () => { + render(); + + expect(screen.getByRole('dialog', { name: 'Rename profile' })).toBeInTheDocument(); + }); + + it('disables confirm for a blank name', () => { + render(); + + expect(screen.getByRole('button', { name: 'Add profile' })).toBeDisabled(); + }); + + it('confirms on Enter when the name is usable', () => { + const onConfirm = vi.fn(); + render(); + + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }); + + expect(onConfirm).toHaveBeenCalled(); + }); + + it('does not confirm on Enter when the name is blank', () => { + const onConfirm = vi.fn(); + render(); + + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('caps the name at 40 characters', () => { + render(); + + expect(screen.getByRole('textbox')).toHaveAttribute('maxLength', '40'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ui && npx vitest run src/components/panel/ProfilesPanel.test.tsx src/components/panel/ProfileNameDialog.test.tsx` +Expected: FAIL — cannot resolve `./ProfilesPanel` + +- [ ] **Step 3: Create the dialog** + +Create `ui/src/components/panel/ProfileNameDialog.tsx`: + +```tsx +import { PanelAction } from './PanelAction'; +import { useI18n } from '../../i18n/useI18n'; + +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 canConfirm = Boolean(name.trim()); + const title = mode === 'add' ? t('menu.addProfile') : t('menu.renameProfile'); + + return ( +
+
+ ); +} +``` + +- [ ] **Step 4: Create the panel** + +Create `ui/src/components/panel/ProfilesPanel.tsx`: + +```tsx +import { useMemo, useRef, type ReactNode } from 'react'; +import type { Profile } from '../../types/profile'; +import type { Shot } from '../../types/shot'; +import { filterShotsByProfile } 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); + // The active profile can never be removed: deleting the profile whose shots + // are on screen is a trap, and the server refuses it too. + const canRemove = profiles.length > 1; + const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? null; + const shotCounts = useMemo(() => { + const counts: Record = {}; + for (const profile of profiles) { + counts[profile.id] = filterShotsByProfile(shots, profile.id).length; + } + return counts; + }, [profiles, shots]); + + return ( +
+ +
+ {!loaded ? ( + +
+ ); +} +``` + +- [ ] **Step 5: Update views, barrel, and CSS** + +In `ui/src/components/panel/views.ts`, change the `PanelView` union member `'players'` to `'profiles'` and the `PANEL_VIEWS` entry `{ id: 'players', label: 'Players' }` to `{ id: 'profiles', label: 'Profiles' }`. + +In `ui/src/components/panel/index.ts`, replace the `PlayersPanel` and `AddPlayerDialog` exports with `ProfilesPanel` and `ProfileNameDialog`. + +In `ui/src/components/panel/panel.css`, rename every `players-panel__*` selector to `profiles-panel__*` and every `add-player-modal*` selector to `profile-name-modal*`. Then add rules for the two new elements, matching the existing `profiles-panel__remove` styling for the rename button and a neutral placeholder block for the skeleton: + +```css +.profiles-panel__skeleton { + min-height: 6rem; + border-radius: var(--radius-md, 0.75rem); + background: var(--surface-2, rgba(255, 255, 255, 0.06)); +} +``` + +Position `.profiles-panel__rename` opposite `.profiles-panel__remove` on the card wrap (mirror the existing `remove` rule's absolute positioning, swapping its horizontal offset to the other side). Match the existing file's custom-property names rather than the placeholders above if they differ. + +- [ ] **Step 6: Delete the superseded components** + +```bash +git rm ui/src/components/panel/PlayersPanel.tsx ui/src/components/panel/PlayersPanel.test.tsx \ + ui/src/components/panel/AddPlayerDialog.tsx ui/src/components/panel/AddPlayerDialog.test.tsx +``` + +- [ ] **Step 7: Run the component tests** + +Run: `cd ui && npx vitest run src/components/panel/ProfilesPanel.test.tsx src/components/panel/ProfileNameDialog.test.tsx` +Expected: PASS once Task 8's i18n keys exist. If run before Task 8, the i18n lookups return the raw key and these assertions fail on copy — in that case do Task 8 first, then return here. (The plan orders i18n later only because it is mechanical; either order works.) + +- [ ] **Step 8: Stage (do not commit)** + +```bash +git add ui/src/components/panel/ +``` + +--- + +## Task 7: App wiring + +Delete the reconciliation code and drive everything from the mirror. + +**Files:** +- Modify: `ui/src/App.tsx:8,12,22,30,38,75-84,118-119,131-139,148-154,182-199,226-230,254,299-309` and the `currentView === 'players'` branch +- Modify: `ui/src/components/panel/{LivePanel,ShotsPanel,StatsPanel,MenuSheet,ClearSessionDialog,PanelFooter,liveMetrics}.tsx|.ts` — rename the `playerName` props and any `player` copy references +- Test: `ui/src/App.test.tsx` and the panel tests above + +**Interfaces:** +- Consumes: `useProfileStore`, `socketService.*Profile*` (Task 5); `ProfilesPanel`, `ProfileNameDialog` (Task 6); `filterShotsByProfile` (Task 4). +- Produces: no new exports. Component props renamed: `LivePanel.playerName` → `LivePanel.profileName`, and the same for any other panel taking a player name for display. + +- [ ] **Step 1: Write the failing test** + +Add to `ui/src/App.test.tsx`: + +```tsx +describe('profile roster', () => { + it('renders the roster once the server snapshot arrives', async () => { + useProfileStore.setState({ + profiles: [ + { id: 'aaa', name: 'Home', created_at: '2026-08-27T10:00:00Z', settings: {} }, + { id: 'bbb', name: 'Range', created_at: '2026-08-27T10:00:00Z', settings: {} }, + ], + activeProfileId: 'aaa', + loaded: true, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Profiles' })); + + expect(await screen.findByText('Range')).toBeInTheDocument(); + }); + + it('emits set_active_profile with the id when a profile is picked', async () => { + const setActiveProfile = vi.spyOn(socketService, 'setActiveProfile'); + useProfileStore.setState({ + profiles: [ + { id: 'aaa', name: 'Home', created_at: '2026-08-27T10:00:00Z', settings: {} }, + { id: 'bbb', name: 'Range', created_at: '2026-08-27T10:00:00Z', settings: {} }, + ], + activeProfileId: 'aaa', + loaded: true, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Profiles' })); + fireEvent.click(await screen.findByText('Range')); + + expect(setActiveProfile).toHaveBeenCalledWith('bbb'); + }); +}); +``` + +Match the existing `App.test.tsx` conventions for rendering and for reaching a panel — read the file's existing tests and reuse their navigation helper rather than assuming the footer button's accessible name. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd ui && npx vitest run src/App.test.tsx` +Expected: FAIL — `usePlayerStore` import error or missing `Profiles` tab + +- [ ] **Step 3: Rewire App.tsx** + +Replace the imports on lines 8, 12, 22, 30, 38: + +```tsx +import { useProfileStore } from './stores/useProfileStore'; +``` +(delete the `shouldEchoSelectionToServer` import entirely) +```tsx + ProfileNameDialog, + ProfilesPanel, +``` +```tsx +import { filterShotsByProfile } from './types/shot'; +import type { Profile } from './types/profile'; +``` + +Replace the store selector (lines 75-84) with: + +```tsx + const { profiles, activeProfileId, profilesLoaded } = useProfileStore( + useShallow((state) => ({ + profiles: state.profiles, + activeProfileId: state.activeProfileId, + profilesLoaded: state.loaded, + })) + ); + const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? null; + const activeProfileName = activeProfile?.name ?? ''; +``` +(delete the `serverPlayerName` line) + +Replace the dialog state (lines 118-119) with: + +```tsx + const [profileDialog, setProfileDialog] = useState<{ mode: 'add' | 'rename'; target: Profile | null } | null>(null); + const [profileDialogName, setProfileDialogName] = useState(''); +``` + +**Delete lines 131-139 entirely** — the `appliedServerPlayer` reconciliation. The server is the only source now, so there is nothing to reconcile. Leave the `appliedServerClub` block above it alone; club still comes from the simulator. + +**Delete lines 148-154 entirely** — the echo-on-connect effect, along with its comment. `socketService` requests `get_profiles` on connect instead. + +Replace the handlers (lines 182-199) with: + +```tsx + const handleSelectProfile = (profileId: string) => { + socketService.setActiveProfile(profileId); + setCurrentView('live'); + }; + + 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 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(); + }; +``` + +Replace the shot scoping (lines 226-230): + +```tsx + const profileShots = filterShotsByProfile(shots, activeProfileId); + const profileLatestShot = profileShots[profileShots.length - 1] ?? null; + const profileIsNewShot = Boolean( + isNewShot && latestShot && profileLatestShot && latestShot.timestamp === profileLatestShot.timestamp + ); +``` + +Then rename every downstream use of `playerShots` / `playerLatestShot` / `playerIsNewShot` / `selectedPlayer` to the `profile*` equivalents (`activeProfileName` where a display name is wanted), replace the `addPlayerAction` definition (line 254) with: + +```tsx + const addProfileAction = {t('menu.addProfile')}; +``` + +replace the `currentView === 'players'` branch with a `'profiles'` branch rendering: + +```tsx + +``` + +and replace the `AddPlayerDialog` render with: + +```tsx + {profileDialog ? ( + + ) : null} +``` + +Finally, update the `clearSession` call site to pass `activeProfileId` instead of the selected player name, and the `LivePanel` `playerName={selectedPlayer}` prop to `profileName={activeProfileName}`. + +- [ ] **Step 4: Rename the downstream panel props** + +Run `grep -rn "playerName\|player_name\|selectedPlayer" ui/src --include=*.tsx --include=*.ts` and rename each remaining occurrence: props named `playerName` become `profileName`, and any `filterSwingSpeedShots({ playerName })` call becomes `{ profileId }` passing `activeProfileId`. Update each component's test alongside it. + +- [ ] **Step 5: Typecheck and test** + +Run: +```bash +cd ui && npm run lint && npx tsc --noEmit && npm test +``` +Expected: clean lint, no type errors, all tests pass. `tsc --noEmit` is the real gate here — it finds every consumer the greps missed. + +- [ ] **Step 6: Stage (do not commit)** + +```bash +git add ui/src +``` + +--- + +## Task 8: Locale strings + +Rename the keys and translate the new copy properly. Leaving "Players" in the Spanish file would be a regression, not a rename. + +**Files:** +- Modify: `ui/src/i18n/en.ts`, `ui/src/i18n/es.ts`, `ui/src/i18n/fr.ts`, `ui/src/i18n/pt.ts` +- Test: `ui/src/i18n/i18n.test.ts` (existing key-parity check — no new test needed, it fails automatically on a missed key) + +**Interfaces:** +- Consumes: nothing. +- Produces: the keys `nav.profiles`, `profiles.rosterAria`, `profiles.shots`, `profiles.shot`, `profiles.namePlaceholder`, `menu.profile`, `menu.addProfile`, `menu.renameProfile`, `menu.renameProfileNamed`, `menu.removeProfile`, `shots.colProfile`, `metric.profileImplement`. The keys `nav.players`, `players.*`, `menu.player`, `menu.addPlayer`, `menu.removePlayer`, `shots.colPlayer`, `metric.playerImplement` are removed. + +- [ ] **Step 1: Run the parity test to see it fail** + +Run: `cd ui && npx vitest run src/i18n/i18n.test.ts` +Expected: FAIL — keys referenced by the new components are missing from every locale. + +- [ ] **Step 2: Update en.ts** + +```ts + 'nav.profiles': 'Profiles', + 'metric.profileImplement': 'profile + implement', + 'shots.colProfile': 'Profile', + 'profiles.rosterAria': 'Profiles', + 'profiles.shots': '{count} shots', + 'profiles.shot': '{count} shot', + 'profiles.namePlaceholder': 'Name', + 'menu.profile': 'Profile', + 'menu.addProfile': 'Add profile', + 'menu.renameProfile': 'Rename profile', + 'menu.renameProfileNamed': 'Rename {name}', + 'menu.removeProfile': 'Remove {name}', + 'clearSession.detail': "This removes {name}'s shots. Other profiles are kept.", +``` + +- [ ] **Step 3: Update es.ts** + +```ts + 'nav.profiles': 'Perfiles', + 'metric.profileImplement': 'perfil + implemento', + 'shots.colProfile': 'Perfil', + 'profiles.rosterAria': 'Perfiles', + 'profiles.shots': '{count} golpes', + 'profiles.shot': '{count} golpe', + 'profiles.namePlaceholder': 'Nombre', + 'menu.profile': 'Perfil', + 'menu.addProfile': 'Añadir perfil', + 'menu.renameProfile': 'Renombrar perfil', + 'menu.renameProfileNamed': 'Renombrar {name}', + 'menu.removeProfile': 'Eliminar {name}', + 'clearSession.detail': 'Esto elimina los golpes de {name}. Los demás perfiles se conservan.', +``` + +- [ ] **Step 4: Update fr.ts** + +```ts + 'nav.profiles': 'Profils', + 'metric.profileImplement': 'profil + accessoire', + 'shots.colProfile': 'Profil', + 'profiles.rosterAria': 'Profils', + 'profiles.shots': '{count} coups', + 'profiles.shot': '{count} coup', + 'profiles.namePlaceholder': 'Nom', + 'menu.profile': 'Profil', + 'menu.addProfile': 'Ajouter un profil', + 'menu.renameProfile': 'Renommer le profil', + 'menu.renameProfileNamed': 'Renommer {name}', + 'menu.removeProfile': 'Supprimer {name}', + 'clearSession.detail': 'Cela supprime les coups de {name}. Les autres profils sont conservés.', +``` + +- [ ] **Step 5: Update pt.ts** + +```ts + 'nav.profiles': 'Perfis', + 'metric.profileImplement': 'perfil + implemento', + 'shots.colProfile': 'Perfil', + 'profiles.rosterAria': 'Perfis', + 'profiles.shots': '{count} tacadas', + 'profiles.shot': '{count} tacada', + 'profiles.namePlaceholder': 'Nome', + 'menu.profile': 'Perfil', + 'menu.addProfile': 'Adicionar perfil', + 'menu.renameProfile': 'Renomear perfil', + 'menu.renameProfileNamed': 'Renomear {name}', + 'menu.removeProfile': 'Remover {name}', + 'clearSession.detail': 'Isto remove as tacadas de {name}. Os outros perfis são mantidos.', +``` + +Match each file's existing plural/formatting conventions — if a locale file already handles plurals differently from `en`, follow its pattern rather than the literal strings above. + +- [ ] **Step 6: Confirm no player keys survive** + +Run: `grep -rn "player" -i ui/src/i18n/` +Expected: no output. + +- [ ] **Step 7: Run the i18n and component tests** + +Run: `cd ui && npx vitest run src/i18n src/components/panel` +Expected: PASS + +- [ ] **Step 8: Stage (do not commit)** + +```bash +git add ui/src/i18n +``` + +--- + +## Task 9: Mock server + +`scripts/start-kiosk.sh --mock` is the documented dev path, so the mock must speak the same protocol. + +**Files:** +- Modify: `ui/mock-server/session.ts:61,89,123-126,135,142-145`, `ui/mock-server/handlers.ts:81-100`, `ui/mock-server/shotGenerator.ts:114,146` + +**Interfaces:** +- Consumes: the socket contract from Task 3. +- Produces: `MockSession.profiles: Profile[]`, `.activeProfileId: string`, `.snapshot()`, `.addProfile(name)`, `.renameProfile(id, name)`, `.removeProfile(id)`, `.setActiveProfile(id)`, `.clearProfile(id)`; `generateShot({ club, profileId, profileName })`. + +- [ ] **Step 1: Replace the session's player state** + +In `ui/mock-server/session.ts`, replace the `playerName = 'Player 1';` field with an in-memory roster mirroring `ProfileStore`'s invariants: + +```ts + profiles: Profile[] = [ + { id: 'mock-profile-1', name: 'Profile 1', created_at: '2026-01-01T00:00:00Z', settings: {} }, + ]; + activeProfileId = 'mock-profile-1'; + private nextProfileNumber = 2; + + 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. + if (profileId === this.activeProfileId || this.profiles.length <= 1) 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); + } +``` + +Import `Profile` from `../src/types/profile` (match the file's existing import style for shared types). Delete the old `setPlayer` and `clearPlayer` methods. In the session-state payload (line 89) delete the `player_name` entry. In `simulateShot` (line 135), pass the active profile: + +```ts + const shot = generateShot({ + club: this.club, + profileId: this.activeProfile.id, + profileName: this.activeProfile.name, + }); +``` + +- [ ] **Step 2: Replace the mock socket handlers** + +In `ui/mock-server/handlers.ts`, replace the `set_player` and `clear_session` handlers (lines 81-100) with: + +```ts + 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 }); + }); +``` + +Also emit `profiles` on connection, next to whatever the file already emits on `connection`. + +- [ ] **Step 3: Update the shot generator** + +In `ui/mock-server/shotGenerator.ts`, replace the `playerName: string;` option (line 114) with `profileId: string; profileName: string;` and the `player_name: options.playerName,` field (line 146) with: + +```ts + profile_id: options.profileId, + profile_name: options.profileName, +``` + +- [ ] **Step 4: Verify the mock manually** + +Run: `cd ui && npm run dev` (with the mock server per the repo's usual dev command), open the UI, and confirm: the Profiles tab lists `Profile 1`; adding a profile makes it active; renaming updates the card; the ✕ is absent on the active card; simulating a shot attributes it to the active profile; and clearing removes only that profile's shots. + +- [ ] **Step 5: Lint and stage (do not commit)** + +```bash +cd ui && npm run lint +git add ui/mock-server +``` + +--- + +## Task 10: End-to-end tests, docs, and full verification + +**Files:** +- Modify: `ui/tests/e2e/app.spec.ts`, `ui/tests/e2e/helpers.ts`, `ui/tests/e2e/fixtures/camera-replay.tsx`, `ui/tests/e2e/camera-replay.spec.ts` +- Modify: `ui/README.md`, `docs/CHANGELOG.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: no code exports. + +- [ ] **Step 1: Update the e2e helpers and specs** + +Run `grep -rn "player" -i ui/tests/e2e/` and update each hit: the `Players` tab becomes `Profiles`, `player_name` in any fixture shot becomes `profile_id` / `profile_name`, and any helper that seeds a player seeds a profile via the socket events instead. + +- [ ] **Step 2: Add an e2e case for rename** + +Add to `ui/tests/e2e/app.spec.ts`, following the file's existing test structure and locators: + +```ts +test('renaming a profile keeps its shots', async ({ page }) => { + await page.goto('/'); + 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('button', { name: 'Rename profile' }).last().click(); + + await expect(page.getByText('Range')).toBeVisible(); +}); +``` + +- [ ] **Step 3: Run the e2e suite** + +Run: `cd ui && npx playwright test` +Expected: PASS + +- [ ] **Step 4: Update the docs** + +In `ui/README.md`, replace the player references with profiles and document the socket contract: the `profiles` snapshot event and the five client→server mutations. + +In `docs/CHANGELOG.md`, add an entry under the current unreleased section: + +```markdown +- **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`. Profiles can be renamed without orphaning + their shots. 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. +``` + +- [ ] **Step 5: Full verification** + +Run every gate: + +```bash +uv run pytest tests/ -v +uv run pylint src/openflight/ --fail-under=9 +uv run ruff check src/openflight/ +uv run ruff format --check src/openflight/ +cd ui && npm run lint && npx tsc --noEmit && npm test && npx playwright test +``` + +Expected: all pass, pylint ≥ 9.0. + +- [ ] **Step 6: Final sweep for stragglers** + +Run: +```bash +grep -rn "player" -i src/openflight tests ui/src ui/mock-server ui/tests \ + | grep -v "sim/\|gspro/\|test_sim\|test_gspro\|log_sim_player\|sim_player" +``` +Expected: no output. Every remaining hit must be a simulator-protocol reference; anything else is a missed rename. + +- [ ] **Step 7: Stage (do not commit)** + +```bash +git add ui/tests ui/README.md docs/CHANGELOG.md +``` + +Report the full verification output to the repo owner and ask whether to commit. + +--- + +## Self-Review + +**Spec coverage:** Data model → Task 1. Store file, invariants, atomic writes → Task 1. Shot attribution (`profile_id` + `profile_name`, exact match) → Tasks 2 and 4. Socket contract (snapshot event, five mutations, rejected-input behaviour) → Tasks 3, 5, 9. Server changes (global removed, helpers collapsed, `session_state` stripped) → Task 3. UI store with no localStorage → Task 5. `playerSocketSync` deletion → Task 5. Reconciliation deletion → Task 7. Delete-active forbidden → Tasks 1, 3, 6, 7. Rename included → Tasks 1, 3, 6, 7, 10. Component and CSS renames → Task 6. Locales with real translations → Task 8. Mock server → Task 9. Clean break, no migration → no task reads old data anywhere. Testing section → each task's tests plus Task 10. `sim`/`gspro` untouched → Global Constraints and the Task 10 sweep. + +**Type consistency:** `profile_id` / `profile_name` are used identically in Python (Tasks 2, 3) and TypeScript (Tasks 4-9). `ProfileStore` method names in Task 1's Interfaces match every call site in Task 3. `ProfilesSnapshot` (Task 4) matches `snapshot()`'s output (Task 1) and the mock's `snapshot()` (Task 9). `useProfileStore`'s `loaded` flag (Task 5) matches `ProfilesPanel`'s `loaded` prop (Task 6) and `profilesLoaded` in `App.tsx` (Task 7). `socketService` method names (Task 5) match the handler event names (Task 3) and the mock's listeners (Task 9). i18n keys used in Task 6's components are all defined in Task 8. + +**Known ordering wrinkle:** Task 6's component tests assert on English copy defined in Task 8. Either do Task 8 before Task 6, or expect those two assertions to fail until Task 8 lands. Flagged inline in Task 6, Step 7. diff --git a/docs/superpowers/specs/2026-08-27-profiles-design.md b/docs/superpowers/specs/2026-08-27-profiles-design.md new file mode 100644 index 000000000..0065f8408 --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-profiles-design.md @@ -0,0 +1,290 @@ +# Profiles (replacing Players) + +**Date:** 2026-08-27 +**Status:** Approved design, ready for implementation planning + +## Problem + +Today a "player" is a bare name string. `usePlayerStore` keeps a list of names and a +selected name in browser `localStorage`; the socket contract is `set_player` → +`player_changed` carrying `player_name`; the server holds a single global +`current_player_name`; and every `Shot` and swing-speed event is stamped with +`player_name` and filtered by case-insensitive name match. + +Three problems follow from that model: + +1. **A name is not an identity.** Renaming is impossible without orphaning every shot + already recorded under the old name. +2. **The roster is browser-local.** Nothing server-side can read it, so no future feature + can attach settings to a profile. A reflashed kiosk or a second browser loses the roster. +3. **Two sources of truth race.** `session_state.player_name` (a connect-time snapshot) and + `player_changed` (a live update) can disagree. `ui/src/services/playerSocketSync.ts` + exists solely to referee that race, and `App.tsx:131-153` runs a reconciliation dance + for the same reason. + +"Player" is also the wrong word. The thing shots are attributed to may be a person *or* a +place — a range bay, a home net, a course — and the current noun excludes half of that. + +## Goals + +- Replace "player" with "profile" across the UI and socket layer. +- Give each profile a stable id, so renaming never orphans shots. +- Persist profiles server-side, so later features can attach settings to them. +- Collapse the two sources of truth into one, deleting the reconciliation code. + +## Non-goals + +- Defining any specific profile setting. `settings` is an open dict; later features claim + keys in it. No setting is specified or consumed by this work. +- Renaming anything in `src/openflight/sim/` or `src/openflight/gspro/`. `PlayerState` + and GSPro's `Player` fields are an external wire protocol, not our terminology. +- Cloud sync of profiles. +- Migrating existing player data. This is a clean break (see Migration). + +## Data model + +A profile record is untyped — a profile is just a name, whether it denotes a person or a +place: + +```json +{ + "id": "a3f2c1d0e5b6478f9a0b1c2d3e4f5061", + "name": "Home Range", + "created_at": "2026-08-27T10:14:03Z", + "settings": {} +} +``` + +- `id` — uuid4 hex, generated, never derived from the name. Renames are free. +- `name` — trimmed, capped at 40 characters. **Not unique**; `id` is the key, so two + profiles named "Range" are legal. +- `created_at` — ISO 8601 UTC. +- `settings` — an open dict the server round-trips untouched. This is the extension point + for later features; nothing in this work reads or writes it. + +### Store + +File: `~/.config/openflight/profiles.json` (the established config dir, alongside +`cloud/config.py`'s `cloud.json` and the camera exposure state). + +```json +{ "profiles": [ ... ], "active_profile_id": "a3f2..." } +``` + +New module `src/openflight/profiles.py` owning a `ProfileStore` class: + +| Method | Behaviour | +|---|---| +| `list()` | All profiles, insertion-ordered | +| `get_active()` | The active profile record | +| `add(name)` | Append and make active; returns the new record | +| `rename(id, name)` | Change `name` in place; `id` and shot attribution unaffected | +| `remove(id)` | Delete; **rejected** if `id` is active or the last profile | +| `set_active(id)` | Change `active_profile_id`; rejected if `id` is unknown | + +Persistence details: + +- **Atomic writes** — write to a temp file in the same directory, then `os.replace`. A + power cut on the Pi mid-write must not truncate the roster. +- **Corrupt or missing file** — log and fall back to a freshly seeded store containing one + default profile. Never raise into server startup. +- **Concurrency** — a single in-process lock. This is a kiosk with one writer; no file + locking. +- **Roster cap** — 12 profiles, matching today's limit. + +### Invariants + +- At least one profile always exists. +- `active_profile_id` always names a live profile. +- Rejected mutations change nothing and are answered with the unchanged state. + +### Shot attribution + +`Shot` and `SwingSpeedEvent` gain `profile_id` and `profile_name` and drop `player_name`. + +- `profile_id` is the filter key, matched **exactly** — no case folding. The existing + `normalizePlayerName` (`ui/src/types/shot.ts:157`) and `_normalize_player_name` / + `_player_matches` (`server.py:2144-2152`) are deleted outright. Case-insensitive name + matching is a bug source: two profiles differing only in case currently collide. +- `profile_name` is a denormalized snapshot taken at stamp time, so session JSONL stays + human-readable without joining against `profiles.json`. It is never used for filtering. + +## Socket contract + +### Server → client + +One authoritative snapshot event, emitted on connect and after **every** mutation +(including rejected ones): + +``` +"profiles" { profiles: [{id, name, created_at, settings}], active_profile_id } +``` + +Roster and selection always arrive together and therefore cannot disagree. `session_state` +drops `player_name` and carries no selection at all — this is what removes the race. +`ui/src/services/playerSocketSync.ts` and its test are **deleted**, not renamed. + +`session_cleared` becomes `{ profile_id, shots }`. + +### Client → server + +``` +"set_active_profile" { profile_id } +"add_profile" { name } → adds and makes active +"rename_profile" { profile_id, name } +"remove_profile" { profile_id } +"clear_session" { profile_id } +``` + +The four roster mutations (`set_active_profile`, `add_profile`, `rename_profile`, +`remove_profile`) each end by broadcasting the `profiles` snapshot. `clear_session` does not — +it mutates shots, not the roster — and answers with `session_cleared` instead. + +### Rejected input + +Unknown `profile_id`, blank name, removing the active profile, and removing the last +profile are all answered with the unchanged snapshot rather than a silent default or an +error event. A confused or stale client self-heals on the next round trip. + +Note that the server rejecting `remove_profile` on the active id is the backstop for the +UI rule below — the invariant holds even if a stale client asks. + +## Server changes + +- `current_player_name` (`server.py:90`) is replaced by the `ProfileStore`. +- `shot.player_name = current_player_name` (`server.py:3125`) becomes `profile_id` / + `profile_name` stamped from `store.get_active()`. Same for the swing-speed event path + (`server.py:3782`). +- `_normalize_player_name`, `_player_matches`, and `_clear_player_rows` + (`server.py:2144-2187`) collapse into `_clear_profile_rows(profile_id)` doing an exact + id match. +- `handle_set_player` (`server.py:2116`) is replaced by the five handlers above. +- `session_logger` field names follow the `Shot` rename. +- `sim/` and `gspro/` are untouched. + +## UI changes + +### Store + +`usePlayerStore` → `useProfileStore`, and it **stops being a source of truth**. It holds +`profiles`, `activeProfileId`, and actions that emit socket events; the `profiles` snapshot +handler replaces state wholesale. + +**No `localStorage`.** The server already tracks the active profile globally, exactly as it +does today with `current_player_name`, so a browser-side copy is a second truth with +nothing to add. Before the socket connects the roster renders a disabled skeleton rather +than a guessed default. Actions no-op while disconnected. + +This deletes `App.tsx:131-153` entirely: the `appliedServerPlayer` tracking, the +echo-on-connect, and the comment explaining why it must not re-emit on change. + +### Behaviour + +- **Deleting the active profile is not allowed.** The ✕ stays hidden on the active card + and the server rejects it. Deleting the profile whose shots are on screen is a usability + trap, not a capability. +- **Renaming is added.** Stable ids make it safe for the first time. The add dialog is + reused with an initial value, wired to `rename_profile`. + +### Renames + +| From | To | +|---|---| +| `PlayersPanel` | `ProfilesPanel` | +| `AddPlayerDialog` | `AddProfileDialog` | +| `players-panel__*` CSS | `profiles-panel__*` | +| `'players'` panel view / tab | `'profiles'` | +| `filterShotsByPlayer` / `excludeShotsByPlayer` | `filterShotsByProfile` / `excludeShotsByProfile`, keyed on `profileId` | +| `SwingSpeedStatsFilter.playerName` | `SwingSpeedStatsFilter.profileId` | +| `socketService.setPlayer` / `clearSession(playerName)` | `setActiveProfile` / `clearSession(profileId)` | + +All four locale files (`en`, `es`, `fr`, `pt`) get the key renames **plus real +translations** — Perfiles / Profils / Perfis. Affected keys: `nav.players`, +`players.rosterAria`, `players.shots`, `players.shot`, `players.namePlaceholder`, +`menu.player`, `menu.addPlayer`, `menu.removePlayer`, `shots.colPlayer`, +`metric.playerImplement`, `clearSession.detail`. + +The mock server (`ui/mock-server/`) implements the same profile events over an in-memory +store, so `--mock` keeps working. + +## Migration + +**Clean break.** No migration of existing player data. + +- On first run the server seeds `profiles.json` with a single profile named `Profile 1`. +- Existing browser `localStorage` player rosters (`openflight-players`, + `openflight-selected-player`) are simply abandoned in place on existing kiosks, not actively + removed. Adding removal code would itself be the migration cruft the clean break set out to + avoid. +- Old session JSONL entries keep their `player_name` field and are simply not filterable by + profile. Nothing reads them at runtime. +- The socket exposes only the new events. No dual-emit compatibility window, so there is no + cruft to remember to delete. + +## Testing + +### Python + +New `tests/test_profiles.py` — `ProfileStore` directly: + +- Missing file seeds a default profile. +- Corrupt JSON falls back to a seeded default rather than raising. +- Atomic write leaves no truncated file on failure. +- Name trimmed and capped at 40 characters. +- Blank name rejected. +- Duplicate names allowed. +- `remove` of the active id rejected. +- `remove` of the last profile rejected. +- `set_active` / `rename` / `remove` with an unknown id are no-ops. +- `settings` round-trips byte-identical through save/load — the guarantee later features + depend on. +- `active_profile_id` always names a live profile after any operation. + +Additions to `tests/test_server.py`: + +- Every mutation handler broadcasts the `profiles` snapshot. +- A rejected mutation broadcasts the **unchanged** snapshot. +- Shots stamp `profile_id` and `profile_name` from the active profile. +- Swing-speed events stamp the same fields. +- `clear_session` scopes by exact id, including two profiles whose names differ only in + case — the case the old code got wrong. +- `session_state` carries no selection field. + +### UI (vitest) + +- `useProfileStore.test.ts` — snapshot replaces state wholesale; actions emit the right + events with the right payloads; actions no-op while disconnected. +- `shot.test.ts` — id-keyed filtering, including shots with a missing or unknown + `profile_id` falling out of every profile. +- `ProfilesPanel.test.tsx` — roster render, shot counts, select, rename, ✕ hidden on the + active card. +- `AddProfileDialog.test.tsx` — add and rename modes. +- `i18n.test.ts` — existing key-parity check catches any missed locale key. +- `App.test.tsx` — reconciliation tests deleted; one added for the pre-connect skeleton. +- E2E `app.spec.ts` and `helpers.ts` updated for the new panel and tab. + +Implementation is test-first per the project's rules. + +### Verification + +``` +uv run pytest tests/ -v +uv run pylint src/openflight/ --fail-under=9 +uv run ruff check src/openflight/ && uv run ruff format --check src/openflight/ +cd ui && npm run lint && npm test +``` + +plus the Playwright e2e run. + +## Decisions and rationale + +| Decision | Rationale | +|---|---| +| Untyped profile (no `person`/`location` kind) | YAGNI. A name is a name; `settings` can differentiate later without a schema enum to maintain. | +| Server-owned JSON store | Later features attaching settings to profiles are mostly server-side concerns. A reflashed kiosk or second browser should not lose the roster. | +| Stamp both `profile_id` and `profile_name` | Id makes renames safe; the denormalized name keeps session JSONL readable without a join. | +| One `profiles` snapshot event | Roster and selection cannot disagree. Deletes the race `playerSocketSync.ts` was written to referee. Cost is the full roster on the wire per change — a rounding error at 12 small records on a LAN socket. | +| No `localStorage` | The server is already globally authoritative for the active profile. A browser copy is a second truth with nothing to add. | +| Delete-active forbidden | Discarding the shots currently on screen is a usability trap. | +| Rename included | It is the concrete payoff of stable ids and the reason to do this rather than a find-and-replace. | +| Clean break, no migration | Single-deployment DIY project; a compat window would be cruft with no consumer. | diff --git a/src/openflight/launch_monitor.py b/src/openflight/launch_monitor.py index 2231c6761..130ede6ad 100644 --- a/src/openflight/launch_monitor.py +++ b/src/openflight/launch_monitor.py @@ -287,7 +287,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..e959f96a0 --- /dev/null +++ b/src/openflight/profiles.py @@ -0,0 +1,227 @@ +"""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" +DEFAULT_PROFILE_NAME = "Profile 1" +MAX_PROFILES = 12 +MAX_NAME_LENGTH = 40 + + +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 returned by reference, not copied: a caller that + holds onto it and mutates it later bypasses the store's lock and + ``save()`` path. + """ + return { + "id": self.id, + "name": self.name, + "created_at": self.created_at, + "settings": 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 = Path(path).expanduser() if path else DEFAULT_PROFILES_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.""" + 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.""" + 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 9fe961d5e..5165e4cf0 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -31,6 +31,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 ( @@ -87,7 +88,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", @@ -906,7 +918,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(), "peak_magnitude": shot.peak_magnitude, # Launch angle data @@ -1961,7 +1974,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: @@ -2081,6 +2093,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: @@ -2113,15 +2126,53 @@ 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() - 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("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() + + +@socketio.on("remove_profile") +def handle_remove_profile(data=None): + """Delete a profile. Refused for the active or the last one.""" + get_profile_store().remove(_payload_dict(data).get("profile_id")) + _emit_profiles() @socketio.on("set_training_implement") @@ -2141,44 +2192,29 @@ 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 _clear_profile_rows(profile_id: str) -> None: + """Remove one profile's shots or swing-speed reps from the active monitor. - -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_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 @@ -2189,14 +2225,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()}, ) @@ -3122,7 +3157,9 @@ def on_shot_detected(shot: Shot): """Callback when a shot is detected - emit to all clients.""" global ball_detected, ball_detection_confidence # pylint: disable=global-statement - 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) # Snapshot orientation before IWR capture can block, and select only data @@ -3633,7 +3670,8 @@ def on_shot_detected(shot: Shot): 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={ "iwr6843": (round(iwr6843_ms, 1) if iwr6843_ms is not None else None), @@ -3717,7 +3755,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, } @@ -3734,7 +3773,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, @@ -3779,7 +3819,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 {} diff --git a/src/openflight/session_logger.py b/src/openflight/session_logger.py index 7b66ace2e..9d497c48b 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, ): """ @@ -430,7 +431,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/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 000000000..e0279da3a --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,315 @@ +"""Tests for the persistent profile roster.""" + +import json + +import pytest + +from openflight.profiles import ( + DEFAULT_PROFILE_NAME, + MAX_PROFILES, + ProfileStore, +) + + +@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} + + +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] diff --git a/tests/test_server.py b/tests/test_server.py index 1309f693f..05ab0a716 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1427,7 +1427,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 @@ -1806,7 +1806,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", } @@ -1834,29 +1835,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.""" @@ -2106,6 +2085,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.""" @@ -2329,122 +2360,326 @@ 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_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"}) - - assert [shot.player_name for shot in monitor.get_shots()] == ["Alex"] + server_module.handle_clear_session({"profile_id": active.id}) - 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..d6a31e67a 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,41 @@ 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 or the last remaining profile. +- `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` (denormalized at capture time), +not a live reference — renaming a profile does not rewrite past shots' display +name. + **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 +160,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 +177,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..cdabea8a8 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,54 @@ 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. + if (profileId === this.activeProfileId || this.profiles.length <= 1) 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 +168,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/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..029bec23d --- /dev/null +++ b/ui/src/components/panel/ProfileNameDialog.test.tsx @@ -0,0 +1,59 @@ +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=""'); + }); +}); diff --git a/ui/src/components/panel/ProfileNameDialog.tsx b/ui/src/components/panel/ProfileNameDialog.tsx new file mode 100644 index 000000000..72389bf94 --- /dev/null +++ b/ui/src/components/panel/ProfileNameDialog.tsx @@ -0,0 +1,54 @@ +import { PanelAction } from './PanelAction'; +import { useI18n } from '../../i18n/useI18n'; + +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 canConfirm = Boolean(name.trim()); + const title = mode === 'add' ? t('menu.addProfile') : t('menu.renameProfile'); + + return ( +
+
+ ); +} diff --git a/ui/src/components/panel/ProfilesPanel.test.tsx b/ui/src/components/panel/ProfilesPanel.test.tsx new file mode 100644 index 000000000..21dd34a53 --- /dev/null +++ b/ui/src/components/panel/ProfilesPanel.test.tsx @@ -0,0 +1,83 @@ +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 the rest', () => { + const html = render(); + + expect(html).not.toContain('aria-label="Remove Home"'); + expect(html).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('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..2f35da2cd --- /dev/null +++ b/ui/src/components/panel/ProfilesPanel.tsx @@ -0,0 +1,106 @@ +import { useMemo, useRef, type ReactNode } from 'react'; +import type { Profile } from '../../types/profile'; +import type { Shot } from '../../types/shot'; +import { filterShotsByProfile } 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); + // The active profile can never be removed: deleting the profile whose shots + // are on screen is a trap, and the server refuses it too. + const canRemove = profiles.length > 1; + const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? null; + const shotCounts = useMemo(() => { + const counts: Record = {}; + for (const profile of profiles) { + counts[profile.id] = filterShotsByProfile(shots, profile.id).length; + } + return counts; + }, [profiles, 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 42d1c7239..3efdba8e1 100644 --- a/ui/src/components/panel/liveMetrics.ts +++ b/ui/src/components/panel/liveMetrics.ts @@ -215,7 +215,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 b2628fc6f..2ef6680c9 100644 --- a/ui/src/components/panel/panel.css +++ b/ui/src/components/panel/panel.css @@ -721,9 +721,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; @@ -737,13 +737,13 @@ -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; @@ -762,12 +762,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; @@ -777,7 +777,7 @@ max-width: 100%; } -.players-panel__count { +.profiles-panel__count { font-weight: 600; font-size: 0.6875rem; letter-spacing: 0.14em; @@ -785,7 +785,7 @@ color: var(--color-text); } -.players-panel__remove { +.profiles-panel__remove { position: absolute; top: 8px; right: 8px; @@ -802,11 +802,41 @@ touch-action: none; } -.players-panel__remove:hover, -.players-panel__remove:active { +.profiles-panel__remove:hover, +.profiles-panel__remove:active { color: var(--color-danger); } +.profiles-panel__rename { + position: absolute; + top: 8px; + right: 60px; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--color-text-faint); + font-size: 0.875rem; + cursor: pointer; + touch-action: none; +} + +.profiles-panel__rename:hover, +.profiles-panel__rename:active { + color: var(--color-text); +} + +.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 { @@ -877,14 +907,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); @@ -893,7 +923,7 @@ white-space: nowrap; } -.shots-panel__player-club { +.shots-panel__profile-club { font-weight: 500; font-size: 0.5625rem; letter-spacing: 0.14em; @@ -1358,9 +1388,9 @@ padding: 0 16px; } -/* ------------------------------------------------------- add player modal -- */ +/* ----------------------------------------------------- profile name modal -- */ -.add-player-modal { +.profile-name-modal { position: absolute; inset: 0; z-index: 40; @@ -1369,7 +1399,7 @@ justify-content: center; } -.add-player-modal__scrim { +.profile-name-modal__scrim { position: absolute; inset: 0; border: none; @@ -1378,7 +1408,7 @@ cursor: pointer; } -.add-player-modal__dialog { +.profile-name-modal__dialog { position: relative; z-index: 1; width: min(420px, calc(100% - 48px)); @@ -1391,7 +1421,7 @@ border-radius: var(--radius); } -.add-player-modal__title { +.profile-name-modal__title { font-weight: 700; font-size: 0.8125rem; letter-spacing: 0.2em; @@ -1399,7 +1429,7 @@ color: var(--color-text); } -.add-player-modal__input { +.profile-name-modal__input { display: block; box-sizing: border-box; width: 100%; @@ -1420,18 +1450,18 @@ 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__actions { display: flex; gap: 10px; } @@ -1444,7 +1474,7 @@ color: var(--color-text-muted); } -.add-player-modal__actions .panel-action { +.profile-name-modal__actions .panel-action { flex: 1; min-width: 0; justify-content: center; @@ -1508,12 +1538,12 @@ 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; } 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..d42d530b0 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,10 @@ 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', 'camera.notConnected': 'Camera not connected', 'camera.detectionOff': 'Ball detection off', @@ -135,10 +135,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 +162,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..0bc9caf4f 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,10 @@ 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', 'camera.notConnected': 'Cámara no conectada', 'camera.detectionOff': 'Detección de bola desactivada', @@ -137,10 +137,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 +164,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..63167ece9 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,10 @@ 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', 'camera.notConnected': 'Caméra non connectée', 'camera.detectionOff': 'Détection de balle désactivée', @@ -137,10 +137,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 +164,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..423499b71 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,10 @@ 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', 'camera.notConnected': 'Câmera não conectada', 'camera.detectionOff': 'Detecção de bola desligada', @@ -137,10 +137,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 +164,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 33c4e2a8e..228518157 100644 --- a/ui/src/services/socketService.ts +++ b/ui/src/services/socketService.ts @@ -15,9 +15,10 @@ import type { DebugReading, RadarConfig, DebugShotLog, SimShotInfo, SimStatus } import type { PowerStatus } from '../types/power'; import { getServerOrigin } from '../utils/serverOrigin'; import { handleShotMessage } 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(); @@ -52,6 +53,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', () => { @@ -103,8 +105,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( @@ -117,7 +119,6 @@ class SocketService { camera_enabled?: boolean; camera_streaming?: boolean; ball_detected?: boolean; - player_name?: string; } ) => { console.log('Session state received:', data); @@ -131,7 +132,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 @@ -184,7 +184,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(); @@ -224,8 +224,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() { @@ -241,10 +257,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..dfe3c6f2f 100644 --- a/ui/src/types/shot.test.ts +++ b/ui/src/types/shot.test.ts @@ -1,60 +1,50 @@ 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 27a70dd06..3325bba2a 100644 --- a/ui/src/types/shot.ts +++ b/ui/src/types/shot.ts @@ -17,7 +17,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; peak_magnitude: number | null; // Launch angle data (from K-LD7 radar (deprecated), camera, or estimation) @@ -141,7 +142,7 @@ export interface SwingSpeedStats { } export interface SwingSpeedStatsFilter { - playerName?: string | null; + profileId?: string | null; trainingImplement?: string | null; club?: string | null; } @@ -154,18 +155,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 { @@ -173,7 +170,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 +193,24 @@ 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('confirms before clearing and only removes that profile, then returns to Live', async ({ page }) => { await withControlSocket(async (socket) => { await simulateShot(socket); }); @@ -218,10 +218,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,7 +233,7 @@ 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); @@ -246,12 +246,81 @@ test('confirms before clearing and only removes that player, then returns to Liv 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('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('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 }) => { await page.setViewportSize({ width: 1024, height: 600 }); diff --git a/ui/tests/e2e/helpers.ts b/ui/tests/e2e/helpers.ts index 2a39b696b..ce3ab3a7f 100644 --- a/ui/tests/e2e/helpers.ts +++ b/ui/tests/e2e/helpers.ts @@ -63,26 +63,45 @@ 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 (the + * display route shows all shots unfiltered, so a shot orphaned under a + * removed profile would still leak into later tests), 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). + */ 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; + } } } From 8e1811bd8925548c33ad86a99bdc85879505ca86 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Thu, 27 Aug 2026 15:23:57 +0100 Subject: [PATCH 02/10] feat(profile): add on-screen keyboard for profile name input in kiosk mode This update introduces a full-screen on-screen keyboard for adding or renaming profiles on the Pi kiosk, addressing the lack of a system keyboard in Chromium's kiosk mode. The keyboard is integrated into the ProfileNameDialog component, enhancing usability for touchscreen interactions. Additionally, relevant translations for keyboard actions have been added to support multiple languages. --- docs/CHANGELOG.md | 6 + .../panel/ProfileNameDialog.test.tsx | 10 ++ ui/src/components/panel/ProfileNameDialog.tsx | 154 ++++++++++++++---- ui/src/components/panel/panel.css | 128 ++++++++++++--- ui/src/i18n/en.ts | 7 + ui/src/i18n/es.ts | 7 + ui/src/i18n/fr.ts | 7 + ui/src/i18n/pt.ts | 7 + ui/tests/e2e/app.spec.ts | 27 ++- 9 files changed, 299 insertions(+), 54 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8e0bd3206..67fa02c70 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ 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. + ### Added - **Profiles replace players.** Shots are now attributed to a server-owned profile (a person *or* a place) with a stable id, persisted to diff --git a/ui/src/components/panel/ProfileNameDialog.test.tsx b/ui/src/components/panel/ProfileNameDialog.test.tsx index 029bec23d..8f17603f4 100644 --- a/ui/src/components/panel/ProfileNameDialog.test.tsx +++ b/ui/src/components/panel/ProfileNameDialog.test.tsx @@ -56,4 +56,14 @@ describe('ProfileNameDialog', () => { 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 index 72389bf94..4ae88897a 100644 --- a/ui/src/components/panel/ProfileNameDialog.tsx +++ b/ui/src/components/panel/ProfileNameDialog.tsx @@ -1,6 +1,26 @@ +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'; @@ -12,43 +32,121 @@ interface ProfileNameDialogProps { 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 ( -
- +
+ 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/panel.css b/ui/src/components/panel/panel.css index 2ef6680c9..11276a1aa 100644 --- a/ui/src/components/panel/panel.css +++ b/ui/src/components/panel/panel.css @@ -1395,30 +1395,17 @@ inset: 0; z-index: 40; display: flex; - align-items: center; - justify-content: center; -} - -.profile-name-modal__scrim { - position: absolute; - inset: 0; - border: none; - padding: 0; - background: var(--color-scrim); - cursor: pointer; + flex-direction: column; + background: var(--color-bg); } -.profile-name-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; } .profile-name-modal__title { @@ -1429,19 +1416,32 @@ color: var(--color-text); } +.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; @@ -1461,9 +1461,59 @@ border-color: var(--color-border); } +.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-dialog__detail { @@ -1577,4 +1627,32 @@ .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; + } } diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts index d42d530b0..129fb71da 100644 --- a/ui/src/i18n/en.ts +++ b/ui/src/i18n/en.ts @@ -111,6 +111,13 @@ export const en = { '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', diff --git a/ui/src/i18n/es.ts b/ui/src/i18n/es.ts index 0bc9caf4f..e081be016 100644 --- a/ui/src/i18n/es.ts +++ b/ui/src/i18n/es.ts @@ -113,6 +113,13 @@ export const es: Messages = { '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', diff --git a/ui/src/i18n/fr.ts b/ui/src/i18n/fr.ts index 63167ece9..fae5bf911 100644 --- a/ui/src/i18n/fr.ts +++ b/ui/src/i18n/fr.ts @@ -113,6 +113,13 @@ export const fr: Messages = { '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', diff --git a/ui/src/i18n/pt.ts b/ui/src/i18n/pt.ts index 423499b71..e3208b366 100644 --- a/ui/src/i18n/pt.ts +++ b/ui/src/i18n/pt.ts @@ -113,6 +113,13 @@ export const pt: Messages = { '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', diff --git a/ui/tests/e2e/app.spec.ts b/ui/tests/e2e/app.spec.ts index 52cd224b2..f125cb6ae 100644 --- a/ui/tests/e2e/app.spec.ts +++ b/ui/tests/e2e/app.spec.ts @@ -240,7 +240,10 @@ test('confirms before clearing and only removes that profile, then returns to Li 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'); @@ -274,6 +277,28 @@ test('clicking the rename control opens the rename dialog and renames the profil 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); From 73388feb6689a92718be9be822884f25e25a2ada Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Thu, 27 Aug 2026 18:50:51 +0100 Subject: [PATCH 03/10] chore: update Prettier configuration and improve test formatting This commit adds an "endOfLine" setting to the Prettier configuration for consistent line endings. Additionally, it refactors the LivePanel test for improved readability by formatting the component props across multiple lines. Minor adjustments were also made to the shot test for consistency in function definition style. --- .editorconfig | 21 ++++++++++++++++++ .gitattributes | 25 ++++++++++++++++++++++ ui/.prettierrc | 3 ++- ui/src/components/panel/LivePanel.test.tsx | 10 ++++++++- ui/src/types/shot.test.ts | 6 ++---- 5 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..3c9990c4d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{js,jsx,ts,tsx,cjs,mjs,css,json,yml,yaml,html}] +indent_style = space +indent_size = 2 + +[*.py] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8a4a21f2b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +# Keep working-tree line endings as LF on every OS. Windows core.autocrlf +# otherwise checks out CRLF, which makes `prettier --check` fail locally +# while CI (Ubuntu) still passes. +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.otf binary +*.zip binary +*.gz binary +*.pkl binary +*.mp4 binary +*.webm binary +*.pdf binary +*.bin binary +*.elf binary +*.hex binary diff --git a/ui/.prettierrc b/ui/.prettierrc index da0c9bca5..75b903ab4 100644 --- a/ui/.prettierrc +++ b/ui/.prettierrc @@ -3,5 +3,6 @@ "trailingComma": "es5", "tabWidth": 2, "semi": true, - "singleQuote": true + "singleQuote": true, + "endOfLine": "lf" } diff --git a/ui/src/components/panel/LivePanel.test.tsx b/ui/src/components/panel/LivePanel.test.tsx index 479e5c103..155ba57b5 100644 --- a/ui/src/components/panel/LivePanel.test.tsx +++ b/ui/src/components/panel/LivePanel.test.tsx @@ -265,7 +265,15 @@ describe('LivePanel', () => { it('still warns on the ready screen so a swing is not taken without a ball', () => { const html = text( renderToString( - + ) ); diff --git a/ui/src/types/shot.test.ts b/ui/src/types/shot.test.ts index dfe3c6f2f..c28ebd732 100644 --- a/ui/src/types/shot.test.ts +++ b/ui/src/types/shot.test.ts @@ -3,8 +3,7 @@ import type { Shot } from './shot'; import { filterShotsByProfile, excludeShotsByProfile } from './shot'; describe('filterShotsByProfile', () => { - const shotWith = (profileId: string | undefined): Shot => - ({ profile_id: profileId, ball_speed_mph: 100 }) as Shot; + 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')]; @@ -33,8 +32,7 @@ describe('filterShotsByProfile', () => { }); describe('excludeShotsByProfile', () => { - const shotWith = (profileId: string | undefined): Shot => - ({ profile_id: profileId, ball_speed_mph: 100 }) as Shot; + 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)]; From b2e14163047f8a3e45241e633398b041bfbeb8b9 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Thu, 27 Aug 2026 20:02:53 +0100 Subject: [PATCH 04/10] refactor(profiles): consolidate action buttons and update styles --- .../components/panel/ProfilesPanel.test.tsx | 11 +++++++ ui/src/components/panel/ProfilesPanel.tsx | 30 +++++++++-------- ui/src/components/panel/panel.css | 32 +++++++------------ 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/ui/src/components/panel/ProfilesPanel.test.tsx b/ui/src/components/panel/ProfilesPanel.test.tsx index 21dd34a53..f71f953ee 100644 --- a/ui/src/components/panel/ProfilesPanel.test.tsx +++ b/ui/src/components/panel/ProfilesPanel.test.tsx @@ -73,6 +73,17 @@ describe('ProfilesPanel', () => { expect(html).toContain('aria-label="Rename Range"'); }); + it('groups rename and remove in a right-aligned actions cluster', () => { + const html = render(); + 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: '' }); diff --git a/ui/src/components/panel/ProfilesPanel.tsx b/ui/src/components/panel/ProfilesPanel.tsx index 2f35da2cd..5644a6d69 100644 --- a/ui/src/components/panel/ProfilesPanel.tsx +++ b/ui/src/components/panel/ProfilesPanel.tsx @@ -78,24 +78,26 @@ export function ProfilesPanel({ {profile.name} {shotLabel} - - {canRemove && !selected ? ( +
- ) : null} + {canRemove && !selected ? ( + + ) : null} +
); }) diff --git a/ui/src/components/panel/panel.css b/ui/src/components/panel/panel.css index 11276a1aa..f9f1f02ee 100644 --- a/ui/src/components/panel/panel.css +++ b/ui/src/components/panel/panel.css @@ -746,7 +746,7 @@ .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; @@ -785,32 +785,17 @@ color: var(--color-text); } -.profiles-panel__remove { +.profiles-panel__actions { position: absolute; top: 8px; right: 8px; - width: 44px; - height: 44px; display: flex; align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--color-text-faint); - font-size: 0.875rem; - cursor: pointer; - touch-action: none; -} - -.profiles-panel__remove:hover, -.profiles-panel__remove:active { - color: var(--color-danger); + justify-content: flex-end; } -.profiles-panel__rename { - position: absolute; - top: 8px; - right: 60px; +.profiles-panel__rename, +.profiles-panel__remove { width: 44px; height: 44px; display: flex; @@ -829,6 +814,11 @@ 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; @@ -1595,7 +1585,7 @@ .profiles-panel__card { min-height: 112px; - padding: 16px 16px 14px; + padding: 16px 96px 14px 16px; } .shots-panel__row-main { From 7b8091dfb487216686f8a143cd42dadb0dd8eba9 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Thu, 27 Aug 2026 20:17:08 +0100 Subject: [PATCH 05/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ui/src/components/panel/ProfilesPanel.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/src/components/panel/ProfilesPanel.tsx b/ui/src/components/panel/ProfilesPanel.tsx index 5644a6d69..f6dd76528 100644 --- a/ui/src/components/panel/ProfilesPanel.tsx +++ b/ui/src/components/panel/ProfilesPanel.tsx @@ -38,11 +38,13 @@ export function ProfilesPanel({ const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? null; const shotCounts = useMemo(() => { const counts: Record = {}; - for (const profile of profiles) { - counts[profile.id] = filterShotsByProfile(shots, profile.id).length; + for (const shot of shots) { + const id = shot.profile_id; + if (!id) continue; + counts[id] = (counts[id] ?? 0) + 1; } return counts; - }, [profiles, shots]); + }, [shots]); return (
From 7b699287705775ccd2d1187608c583c554fd43b8 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Thu, 27 Aug 2026 20:21:05 +0100 Subject: [PATCH 06/10] refactor(docs): remove superpowers documentation --- docs/superpowers/plans/2026-08-27-profiles.md | 2633 ----------------- .../specs/2026-08-27-profiles-design.md | 290 -- 2 files changed, 2923 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-27-profiles.md delete mode 100644 docs/superpowers/specs/2026-08-27-profiles-design.md diff --git a/docs/superpowers/plans/2026-08-27-profiles.md b/docs/superpowers/plans/2026-08-27-profiles.md deleted file mode 100644 index 4953d0b64..000000000 --- a/docs/superpowers/plans/2026-08-27-profiles.md +++ /dev/null @@ -1,2633 +0,0 @@ -# Profiles (replacing Players) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the browser-local, name-keyed "player" concept with server-owned "profiles" that have stable ids, so shots survive renames and later features can attach settings to a profile. - -**Architecture:** A new `ProfileStore` owns `~/.config/openflight/profiles.json` and becomes the single source of truth for the roster and the active selection. The socket exposes one authoritative `profiles` snapshot event (emitted after every mutation, including rejected ones) plus five client→server mutations. Shots are stamped with `profile_id` (the exact-match filter key) and `profile_name` (a denormalized snapshot for readable logs). The UI store becomes a thin mirror with no `localStorage`, which deletes the two-sources-of-truth reconciliation code in `App.tsx`. - -**Tech Stack:** Python 3 / Flask-SocketIO / pytest / pylint / ruff (backend); React + TypeScript / Zustand / socket.io-client / vitest / Playwright (frontend). All Python commands run through `uv run`. - -**Spec:** `docs/superpowers/specs/2026-08-27-profiles-design.md` - -## Global Constraints - -- **Never commit without explicit direction from the repo owner.** Each task ends by staging files and reporting; do not run `git commit` unless asked. (This overrides the usual commit-per-task habit.) -- **Always use `uv` for Python.** `uv run pytest`, `uv run pylint`, `uv run ruff`. Never bare `python`/`pip`/`pytest`. -- **Test-first.** Every task writes the failing test, runs it to confirm it fails for the right reason, then implements. -- **Do not touch `src/openflight/sim/` or `src/openflight/gspro/`.** Their `PlayerState` / `Player` fields are an external wire protocol (GSPro), not our terminology. `session_logger.log_sim_player` also stays as-is. -- **Clean break.** No migration of existing player data, no dual-emit compatibility, no reading of old `localStorage` keys. -- **Profile record shape:** `{"id": , "name": , "created_at": , "settings": {}}`. -- **Store file:** `~/.config/openflight/profiles.json`, contents `{"profiles": [...], "active_profile_id": "..."}`. -- **Limits:** max 12 profiles, name trimmed and capped at 40 characters, default seeded profile is named `Profile 1`. -- **Filtering is exact-match on `profile_id`.** No case folding anywhere. `profile_name` is never used for filtering. -- **Lint gates:** `uv run pylint src/openflight/ --fail-under=9`, `uv run ruff check src/openflight/`, `uv run ruff format --check src/openflight/`, `cd ui && npm run lint`. - ---- - -## File Structure - -**Created** - -| File | Responsibility | -|---|---| -| `src/openflight/profiles.py` | `Profile` dataclass + `ProfileStore`: load, validate, mutate, atomically persist the roster. No Flask, no socket knowledge. | -| `tests/test_profiles.py` | Unit tests for `ProfileStore` in isolation, using `tmp_path`. | -| `ui/src/stores/useProfileStore.ts` | Zustand mirror of the server snapshot. No persistence, no business logic. | -| `ui/src/stores/useProfileStore.test.ts` | Tests for the mirror. | -| `ui/src/components/panel/ProfilesPanel.tsx` | Roster panel: select, rename, remove. | -| `ui/src/components/panel/ProfilesPanel.test.tsx` | Tests for the panel. | -| `ui/src/components/panel/ProfileNameDialog.tsx` | One dialog serving both add and rename (DRY — they differ only in title, button label, and initial value). | -| `ui/src/components/panel/ProfileNameDialog.test.tsx` | Tests for both modes. | - -**Deleted** - -| File | Why | -|---|---| -| `ui/src/services/playerSocketSync.ts` | Existed only to referee the `session_state` vs `player_changed` race. One snapshot event removes the race. | -| `ui/src/services/playerSocketSync.test.ts` | Ditto. | -| `ui/src/stores/usePlayerStore.ts` + `.test.ts` | Replaced by `useProfileStore.ts`. | -| `ui/src/components/panel/PlayersPanel.tsx` + `.test.tsx` | Replaced by `ProfilesPanel`. | -| `ui/src/components/panel/AddPlayerDialog.tsx` + `.test.tsx` | Replaced by `ProfileNameDialog`. | - -**Modified** - -`src/openflight/launch_monitor.py`, `src/openflight/swing_speed.py`, `src/openflight/session_logger.py`, `src/openflight/server.py`, `tests/test_server.py`, `ui/src/types/shot.ts` (+ test), `ui/src/services/sessionClear.ts` (+ test), `ui/src/services/socketService.ts`, `ui/src/stores/useSystemStore.ts`, `ui/src/App.tsx` (+ test), `ui/src/components/panel/index.ts`, `ui/src/components/panel/views.ts`, `ui/src/components/panel/panel.css`, `ui/src/components/panel/LivePanel.tsx` (+ test), `ui/src/components/panel/ShotsPanel.tsx` (+ test), `ui/src/components/panel/StatsPanel.tsx` (+ test), `ui/src/components/panel/PanelHeader.tsx`, `ui/src/components/panel/PanelFooter.tsx` (+ test), `ui/src/components/panel/MenuSheet.tsx` (+ test), `ui/src/components/panel/ClearSessionDialog.tsx` (+ test), `ui/src/components/panel/liveMetrics.ts`, `ui/src/i18n/{en,es,fr,pt}.ts`, `ui/mock-server/{handlers,session,shotGenerator}.ts`, `ui/tests/e2e/{app.spec.ts,helpers.ts}`, `ui/README.md`. - ---- - -## Task 1: ProfileStore - -The persistence layer, with no Flask or socket dependency so it can be tested directly. - -**Files:** -- Create: `src/openflight/profiles.py` -- Test: `tests/test_profiles.py` - -**Interfaces:** -- Consumes: nothing (first task). -- Produces: - - `Profile` dataclass: `id: str`, `name: str`, `created_at: str`, `settings: dict`, method `to_dict() -> dict`. - - `ProfileStore(path: str | Path | None = None)` with: - `list() -> list[Profile]`, `get_active() -> Profile`, `snapshot() -> dict`, - `add(name: str) -> Profile | None`, `rename(profile_id: str, name: str) -> bool`, - `remove(profile_id: str) -> bool`, `set_active(profile_id: str) -> bool`. - - Module constants: `DEFAULT_PROFILES_PATH`, `DEFAULT_PROFILE_NAME = "Profile 1"`, `MAX_PROFILES = 12`, `MAX_NAME_LENGTH = 40`. - - `snapshot()` returns `{"profiles": [, ...], "active_profile_id": str}`. - - All mutators return falsy (`None` / `False`) on rejection and leave state untouched. - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_profiles.py`: - -```python -"""Tests for the persistent profile roster.""" - -import json - -import pytest - -from openflight.profiles import ( - DEFAULT_PROFILE_NAME, - MAX_PROFILES, - ProfileStore, -) - - -@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} - - -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] -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_profiles.py -v` -Expected: FAIL — `ModuleNotFoundError: No module named 'openflight.profiles'` - -- [ ] **Step 3: Implement the store** - -Create `src/openflight/profiles.py`: - -```python -"""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" -DEFAULT_PROFILE_NAME = "Profile 1" -MAX_PROFILES = 12 -MAX_NAME_LENGTH = 40 - - -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.""" - return { - "id": self.id, - "name": self.name, - "created_at": self.created_at, - "settings": 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 = Path(path).expanduser() if path else DEFAULT_PROFILES_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]: - """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.""" - return { - "profiles": [profile.to_dict() for profile in self._profiles], - "active_profile_id": self.get_active().id, - } - - # -- 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.""" - payload = { - "profiles": [profile.to_dict() for profile in self._profiles], - "active_profile_id": self.get_active().id, - } - 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.warning("[profiles] could not save %s: %s", self._path, error) - try: - temp_path.unlink() - except OSError: - pass - - 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 -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_profiles.py -v` -Expected: PASS (all tests) - -- [ ] **Step 5: Lint** - -Run: -```bash -uv run pylint src/openflight/profiles.py --fail-under=9 -uv run ruff check src/openflight/profiles.py -uv run ruff format --check src/openflight/profiles.py -``` -Expected: clean. `pylint` will flag `list` shadowing a builtin as `W0622` only for arguments, not methods — if it complains about the method name, add `# pylint: disable=redefined-builtin` on the method, not a rename: `list()` is the right name for the reader. - -- [ ] **Step 6: Stage (do not commit)** - -```bash -git add src/openflight/profiles.py tests/test_profiles.py -``` - ---- - -## Task 2: Stamp shots with profile_id and profile_name - -Rename the attribution fields on the two event dataclasses and the session logger. The server still passes `current_player_name` at this point — Task 3 rewires it. Keeping this task separate means a reviewer can check the data-shape change without the socket rewrite tangled into it. - -**Files:** -- Modify: `src/openflight/launch_monitor.py:290` -- Modify: `src/openflight/swing_speed.py:29` -- Modify: `src/openflight/session_logger.py:383,433` -- Modify: `src/openflight/server.py` (`shot_to_dict`, `swing_speed_to_dict`, `swing_speed_to_shot_dict`, and the `log_shot` call around line 3636) -- Test: `tests/test_server.py` - -**Interfaces:** -- Consumes: nothing from Task 1. -- Produces: - - `Shot.profile_id: str = ""` and `Shot.profile_name: str = ""` (replacing `player_name`). - - `SwingSpeedEvent.profile_id: str = ""` and `SwingSpeedEvent.profile_name: str = ""`. - - `SessionLogger.log_shot(..., profile_id: Optional[str] = None, profile_name: Optional[str] = None, ...)` writing both keys into the JSONL entry. - - `shot_to_dict` / `swing_speed_to_dict` / `swing_speed_to_shot_dict` emit `"profile_id"` and `"profile_name"` in place of `"player_name"`. - -Defaults are empty strings, not `"Player 1"`: an unstamped shot belongs to no profile and must fall out of every filter. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_server.py` (near the existing `swing_speed_to_shot_dict` tests): - -```python -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, - smash_factor=1.4, - estimated_carry_yards=250, - estimated_carry_range=(245, 255), - 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, - smash_factor=1.4, - estimated_carry_yards=250, - estimated_carry_range=(245, 255), - 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 -``` - -Note: check the existing `Shot(...)` construction in `tests/test_server.py` and copy its exact required arguments — the fields above are illustrative of the shape, and the dataclass may require more or fewer positional values. Match what the file already does rather than inventing a constructor call. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_server.py::TestProfileStamping -v` -Expected: FAIL — `AttributeError` / `KeyError: 'profile_id'` - -- [ ] **Step 3: Rename the dataclass fields** - -In `src/openflight/launch_monitor.py`, replace line 290: - -```python - player_name: str = "Player 1" -``` - -with: - -```python - profile_id: str = "" - profile_name: str = "" -``` - -In `src/openflight/swing_speed.py`, replace line 29 the same way: - -```python - profile_id: str = "" - profile_name: str = "" -``` - -- [ ] **Step 4: Update the session logger** - -In `src/openflight/session_logger.py`, in `log_shot`, replace the `player_name: Optional[str] = None,` parameter (line 383) with: - -```python - profile_id: Optional[str] = None, - profile_name: Optional[str] = None, -``` - -and replace the `"player_name": player_name,` entry (line 433) with: - -```python - "profile_id": profile_id, - "profile_name": profile_name, -``` - -Leave `log_sim_player` untouched — it logs a simulator-protocol event, not our profile. - -- [ ] **Step 5: Update the server payload builders** - -In `src/openflight/server.py`: - -- In `shot_to_dict`, replace `"player_name": shot.player_name,` with: - ```python - "profile_id": shot.profile_id, - "profile_name": shot.profile_name, - ``` -- In `swing_speed_to_dict`, replace `"player_name": event.player_name,` with: - ```python - "profile_id": event.profile_id, - "profile_name": event.profile_name, - ``` -- In `swing_speed_to_shot_dict`, make the same replacement. -- In the `log_shot(...)` call (around line 3636), replace `player_name=shot.player_name,` with: - ```python - profile_id=shot.profile_id, - profile_name=shot.profile_name, - ``` -- In `on_shot_detected`, temporarily replace `shot.player_name = current_player_name` with `shot.profile_name = current_player_name`, and in `on_swing_speed_detected` replace `event.player_name = current_player_name` with `event.profile_name = current_player_name`. These two lines are placeholders that Task 3 replaces with the real store lookup; they exist only so the module imports and the suite runs green between tasks. - -- [ ] **Step 6: Fix the existing tests that assert on player_name** - -Run `uv run pytest tests/test_server.py -v` and update every failing assertion that reads `player_name` from a payload or sets `shot.player_name` to use `profile_name` instead. Do **not** yet touch `TestHandleClearSession` — Task 3 rewrites that class wholesale. - -- [ ] **Step 7: Run the full Python suite** - -Run: `uv run pytest tests/ -v` -Expected: PASS - -- [ ] **Step 8: Stage (do not commit)** - -```bash -git add src/openflight/launch_monitor.py src/openflight/swing_speed.py \ - src/openflight/session_logger.py src/openflight/server.py tests/test_server.py -``` - ---- - -## Task 3: Server profile state and socket handlers - -Replace the `current_player_name` global with the store, swap `set_player` for the five profile mutations, and make every mutation broadcast the authoritative snapshot. - -**Files:** -- Modify: `src/openflight/server.py:90` (the global), `:1964` (`_session_state_payload`), `:2079-2088` (`handle_connect`), `:2116-2124` (`handle_set_player`), `:2144-2200` (normalize/match/clear helpers and `handle_clear_session`), `:3125` and `:3782` (stamping) -- Test: `tests/test_server.py` - -**Interfaces:** -- Consumes: `ProfileStore`, `Profile` from Task 1; `Shot.profile_id` / `.profile_name` from Task 2. -- Produces: - - `server_module.profile_store: Optional[ProfileStore]` — module global, lazily created. - - `get_profile_store() -> ProfileStore`. - - `_emit_profiles() -> None` — broadcasts `("profiles", snapshot)`. - - Socket handlers: `handle_get_profiles()`, `handle_set_active_profile(data)`, `handle_add_profile(data)`, `handle_rename_profile(data)`, `handle_remove_profile(data)`, and a rewritten `handle_clear_session(data=None)`. - - `_clear_profile_rows(profile_id: str) -> None`. - - `current_player_name`, `_normalize_player_name`, `_player_matches`, `_clear_player_rows`, and `handle_set_player` no longer exist. - -The store is created lazily, not at import, so importing the server in a test never writes to the real `~/.config`. Tests set `server_module.profile_store` to a `tmp_path`-backed store. - -- [ ] **Step 1: Write the failing tests** - -Replace the whole `TestHandleClearSession` class in `tests/test_server.py` and add the new classes: - -```python -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_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 - - -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 profile's rows, matched by id.""" - - @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 - - 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_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.socketio, "emit", lambda *args, **kwargs: emitted.append(args) - ) - - server_module.handle_clear_session({"profile_id": james.id}) - - 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["profile_id"] == james.id - assert [entry["profile_name"] for entry in payload["shots"]] == ["Alex"] - - 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.profile_id = alex.id - second = monitor.simulate_shot() - second.profile_id = james.id - - monkeypatch.setattr(server_module, "monitor", monitor) - monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) - - server_module.handle_clear_session() - - assert [shot.profile_id for shot in monitor.get_shots()] == [james.id] - - 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() - 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.socketio, "emit", lambda *args, **kwargs: None) - - server_module.handle_clear_session({"profile_id": lower.id}) - - assert [shot.profile_name for shot in monitor.get_shots()] == ["James"] - - def test_unstamped_shots_belong_to_no_profile(self, store, monkeypatch): - active = store.get_active() - monitor = MockLaunchMonitor() - monitor.connect() - monitor.start() - monitor.simulate_shot() - - monkeypatch.setattr(server_module, "monitor", monitor) - monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) - - server_module.handle_clear_session({"profile_id": active.id}) - - assert len(monitor.get_shots()) == 1 - - 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 - - monkeypatch.setattr(server_module, "monitor", monitor) - monkeypatch.setattr(server_module.socketio, "emit", lambda *args, **kwargs: None) - - 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 -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_server.py -k "Profile or ClearSession or SessionState" -v` -Expected: FAIL — `AttributeError: module 'openflight.server' has no attribute 'handle_get_profiles'` - -- [ ] **Step 3: Replace the global with the store** - -In `src/openflight/server.py`, add to the imports: - -```python -from .profiles import ProfileStore -``` - -Replace line 90: - -```python -current_player_name: str = "Player 1" -``` - -with: - -```python -# 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 -``` - -(`Optional` is already imported in this module; confirm before adding it again.) - -- [ ] **Step 4: Replace the socket handlers** - -Replace `handle_set_player` (lines 2116-2124) with: - -```python -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.""" - profile_id = data.get("profile_id") if isinstance(data, dict) else None - get_profile_store().set_active(profile_id) - _emit_profiles() - - -@socketio.on("add_profile") -def handle_add_profile(data=None): - """Add a profile and make it active.""" - name = data.get("name") if isinstance(data, dict) else None - get_profile_store().add(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 = data if isinstance(data, dict) else {} - get_profile_store().rename(payload.get("profile_id"), payload.get("name")) - _emit_profiles() - - -@socketio.on("remove_profile") -def handle_remove_profile(data=None): - """Delete a profile. Refused for the active or the last one.""" - profile_id = data.get("profile_id") if isinstance(data, dict) else None - get_profile_store().remove(profile_id) - _emit_profiles() -``` - -- [ ] **Step 5: Replace the clear-session helpers** - -Replace `_normalize_player_name`, `_player_matches`, `_clear_player_rows`, and `handle_clear_session` (lines 2144-2200) with: - -```python -def _clear_profile_rows(profile_id: str) -> None: - """Remove one profile'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 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 getattr(event, "profile_id", "") != profile_id - ] - return - - shots = getattr(monitor, "_shots", None) - if shots is not None: - 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 - - if hasattr(monitor, "clear_session"): - monitor.clear_session() - - -@socketio.on("clear_session") -def handle_clear_session(data=None): - """Clear recorded rows for one profile only.""" - raw_id = data.get("profile_id") if isinstance(data, dict) else None - 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", - {"profile_id": profile_id, "shots": _session_shots()}, - ) -``` - -Note the deliberate behaviour change in `_clear_profile_rows`: shots with an empty `profile_id` are never cleared, because they belong to no profile. - -- [ ] **Step 6: Rewire stamping, session_state, and connect** - -- In `_session_state_payload` (line ~1964), delete the `"player_name": current_player_name,` entry entirely. Add nothing in its place. -- In `handle_connect` (line ~2080), add `_emit_profiles()` immediately after `_emit_sim_snapshot()` — outside the `if monitor:` block, so the roster arrives even when no monitor is running. -- In `on_shot_detected` (line ~3125), replace the Task 2 placeholder with: - ```python - active_profile = get_profile_store().get_active() - shot.profile_id = active_profile.id - shot.profile_name = active_profile.name - ``` -- In `on_swing_speed_detected` (line ~3782), replace the placeholder with: - ```python - active_profile = get_profile_store().get_active() - event.profile_id = active_profile.id - event.profile_name = active_profile.name - ``` - -- [ ] **Step 7: Run the tests** - -Run: `uv run pytest tests/test_server.py -v` -Expected: PASS. If any remaining test references `current_player_name` or `handle_set_player`, rewrite it against the store — those names are gone by design. - -- [ ] **Step 8: Confirm no references survive** - -Run: `grep -rn "player_name\|current_player_name\|handle_set_player" src/openflight/ --include=*.py | grep -v "sim/\|gspro/"` -Expected: no output. (`sim/` and `gspro/` hits are correct and must remain.) - -- [ ] **Step 9: Full Python suite and lint** - -Run: -```bash -uv run pytest tests/ -v -uv run pylint src/openflight/ --fail-under=9 -uv run ruff check src/openflight/ && uv run ruff format --check src/openflight/ -``` -Expected: PASS, score ≥ 9.0, clean. - -- [ ] **Step 10: Stage (do not commit)** - -```bash -git add src/openflight/server.py tests/test_server.py -``` - ---- - -## Task 4: UI types and pure filter helpers - -Pure functions first, with no React or socket involvement, so the filtering semantics are locked down before anything consumes them. - -**Files:** -- Modify: `ui/src/types/shot.ts:20,144,157-169,176` -- Modify: `ui/src/types/socket.ts:35` -- Modify: `ui/src/services/sessionClear.ts` -- Test: `ui/src/types/shot.test.ts`, `ui/src/services/sessionClear.test.ts` -- Create: `ui/src/types/profile.ts` - -**Interfaces:** -- Consumes: the server payload shape from Tasks 2-3. -- Produces: - - `ui/src/types/profile.ts`: `export interface Profile { id: string; name: string; created_at: string; settings: Record; }` and `export interface ProfilesSnapshot { profiles: Profile[]; active_profile_id: string; }`. - - `Shot.profile_id?: string` and `Shot.profile_name?: string` (replacing `player_name?`). - - `SwingSpeedEvent.profile_id?: string`, `SwingSpeedEvent.profile_name?: string`. - - `filterShotsByProfile(shots: Shot[], profileId: string): Shot[]` - - `excludeShotsByProfile(shots: Shot[], profileId: string): Shot[]` - - `SwingSpeedStatsFilter.profileId?: string | null` (replacing `playerName`). - - `SessionClearedPayload { profile_id?: string; shots?: Shot[] }`. - -- [ ] **Step 1: Write the failing tests** - -Add to `ui/src/types/shot.test.ts`: - -```ts -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('matches exactly, without folding case', () => { - const shots = [shotWith('AAA'), shotWith('aaa')]; - - expect(filterShotsByProfile(shots, 'aaa')).toEqual([shots[1]]); - }); - - it('excludes unstamped shots from every profile', () => { - const shots = [shotWith(undefined), shotWith('')]; - - expect(filterShotsByProfile(shots, 'aaa')).toEqual([]); - }); - - it('returns nothing for a blank profile id', () => { - const shots = [shotWith('aaa'), shotWith(undefined)]; - - expect(filterShotsByProfile(shots, '')).toEqual([]); - }); -}); - -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]]); - }); -}); -``` - -Add to `ui/src/services/sessionClear.test.ts`: - -```ts -it('prefers the server shot list', () => { - const server = [{ profile_id: 'bbb' } as Shot]; - - expect(remainingShotsAfterClear([{ profile_id: 'aaa' } as Shot], { shots: server })).toEqual(server); -}); - -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]]); -}); -``` - -Also update the existing tests in both files that reference `player_name` / `filterShotsByPlayer` / `filterSwingSpeedShots({ playerName })` to the new names, and add the new imports. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd ui && npx vitest run src/types/shot.test.ts src/services/sessionClear.test.ts` -Expected: FAIL — `filterShotsByProfile is not a function` - -- [ ] **Step 3: Create the profile types** - -Create `ui/src/types/profile.ts`: - -```ts -/** 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; -} -``` - -- [ ] **Step 4: Update the shot types and filters** - -In `ui/src/types/shot.ts`: - -- Replace `player_name?: string;` (line 20) with: - ```ts - profile_id?: string; - profile_name?: string; - ``` -- Replace `playerName?: string | null;` in `SwingSpeedStatsFilter` (line 144) with `profileId?: string | null;` -- Delete `normalizePlayerName` (lines 157-159) entirely — exact id matching needs no normalizer. -- Replace `filterShotsByPlayer` / `excludeShotsByPlayer` (lines 161-169) with: - ```ts - export function filterShotsByProfile(shots: Shot[], profileId: string): Shot[] { - if (!profileId) return []; - return shots.filter((shot) => shot.profile_id === profileId); - } - - export function excludeShotsByProfile(shots: Shot[], profileId: string): Shot[] { - if (!profileId) return shots; - return shots.filter((shot) => shot.profile_id !== profileId); - } - ``` -- In `filterSwingSpeedShots` (line 176), replace the scoping line with: - ```ts - const scoped = filter.profileId ? filterShotsByProfile(shots, filter.profileId) : shots; - ``` - -In `ui/src/types/socket.ts`, replace `player_name?: string;` in `SwingSpeedEvent` (line 35) with: - -```ts - profile_id?: string; - profile_name?: string; -``` - -- [ ] **Step 5: Update sessionClear** - -Replace the body of `ui/src/services/sessionClear.ts`: - -```ts -import type { Shot } from '../types/shot'; -import { excludeShotsByProfile } from '../types/shot'; - -export interface SessionClearedPayload { - profile_id?: string; - shots?: Shot[]; -} - -/** 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?.profile_id) { - return excludeShotsByProfile(currentShots, payload.profile_id); - } - return []; -} -``` - -- [ ] **Step 6: Run the tests** - -Run: `cd ui && npx vitest run src/types/shot.test.ts src/services/sessionClear.test.ts` -Expected: PASS - -- [ ] **Step 7: Stage (do not commit)** - -```bash -git add ui/src/types/profile.ts ui/src/types/shot.ts ui/src/types/shot.test.ts \ - ui/src/types/socket.ts ui/src/services/sessionClear.ts ui/src/services/sessionClear.test.ts -``` - -The project won't typecheck until Task 7 — consumers still call the old names. That's expected; don't chase it here. - ---- - -## Task 5: Profile store mirror and socket wiring - -The store stops being a source of truth. It mirrors the server snapshot and emits mutations. - -**Files:** -- Create: `ui/src/stores/useProfileStore.ts`, `ui/src/stores/useProfileStore.test.ts` -- Delete: `ui/src/stores/usePlayerStore.ts`, `ui/src/stores/usePlayerStore.test.ts`, `ui/src/services/playerSocketSync.ts`, `ui/src/services/playerSocketSync.test.ts` -- Modify: `ui/src/services/socketService.ts`, `ui/src/stores/useSystemStore.ts:14,23,51` - -**Interfaces:** -- Consumes: `Profile`, `ProfilesSnapshot` from Task 4. -- Produces: - - `useProfileStore` with state `profiles: Profile[]`, `activeProfileId: string`, `loaded: boolean`, and action `applySnapshot(snapshot: ProfilesSnapshot): void`. - - Selector helper `export function getActiveProfile(): Profile | null`. - - `socketService.setActiveProfile(profileId: string)`, `.addProfile(name: string)`, `.renameProfile(profileId: string, name: string)`, `.removeProfile(profileId: string)`, `.clearSession(profileId: string)`. - - `useSystemStore.serverPlayerName` / `setServerPlayerName` are removed. - -`loaded` is what the UI gates its skeleton on: false until the first `profiles` event arrives. - -- [ ] **Step 1: Write the failing tests** - -Create `ui/src/stores/useProfileStore.test.ts`: - -```ts -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']); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd ui && npx vitest run src/stores/useProfileStore.test.ts` -Expected: FAIL — cannot resolve `./useProfileStore` - -- [ ] **Step 3: Create the store** - -Create `ui/src/stores/useProfileStore.ts`: - -```ts -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, - }); - }, -})); - -/** The active profile record, or null before the first snapshot. */ -export function getActiveProfile(): Profile | null { - const { profiles, activeProfileId } = useProfileStore.getState(); - return profiles.find((profile) => profile.id === activeProfileId) ?? null; -} -``` - -- [ ] **Step 4: Run the store tests** - -Run: `cd ui && npx vitest run src/stores/useProfileStore.test.ts` -Expected: PASS - -- [ ] **Step 5: Wire the socket service** - -In `ui/src/services/socketService.ts`: - -- Delete the `import { ingestSocketPlayerName } from './playerSocketSync';` line. -- Add `import { useProfileStore } from '../stores/useProfileStore';` and `import type { ProfilesSnapshot } from '../types/profile';` -- In the `connect` handler, add `this.socket?.emit('get_profiles');` alongside the other `get_*` emits. -- Replace the `player_changed` listener (lines 106-108) with: - ```ts - this.socket.on('profiles', (data: ProfilesSnapshot) => { - useProfileStore.getState().applySnapshot(data); - }); - ``` -- In the `session_state` listener, delete `player_name?: string;` from the inline payload type and delete the `ingestSocketPlayerName('session_state', data.player_name);` line. -- Replace the `session_cleared` listener signature with `(data?: { profile_id?: string; shots?: Shot[] })` — the body is unchanged. -- Replace `clearSession(playerName: string)` and `setPlayer(playerName: string)` with: - ```ts - 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 }); - } - ``` - -- [ ] **Step 6: Strip the system store** - -In `ui/src/stores/useSystemStore.ts`, delete `serverPlayerName: string | null;` (line 14), `setServerPlayerName` from the interface (line 23), the `serverPlayerName: null,` initial value, and the `setServerPlayerName` implementation (line 51). Nothing replaces them — the profile store holds this now. - -- [ ] **Step 7: Delete the superseded files** - -```bash -git rm ui/src/stores/usePlayerStore.ts ui/src/stores/usePlayerStore.test.ts \ - ui/src/services/playerSocketSync.ts ui/src/services/playerSocketSync.test.ts -``` - -- [ ] **Step 8: Stage (do not commit)** - -```bash -git add ui/src/stores/useProfileStore.ts ui/src/stores/useProfileStore.test.ts \ - ui/src/services/socketService.ts ui/src/stores/useSystemStore.ts -``` - ---- - -## Task 6: Profiles panel and name dialog - -**Files:** -- Create: `ui/src/components/panel/ProfilesPanel.tsx`, `ui/src/components/panel/ProfilesPanel.test.tsx`, `ui/src/components/panel/ProfileNameDialog.tsx`, `ui/src/components/panel/ProfileNameDialog.test.tsx` -- Delete: `ui/src/components/panel/PlayersPanel.tsx` (+ `.test.tsx`), `ui/src/components/panel/AddPlayerDialog.tsx` (+ `.test.tsx`) -- Modify: `ui/src/components/panel/index.ts`, `ui/src/components/panel/views.ts`, `ui/src/components/panel/panel.css` - -**Interfaces:** -- Consumes: `Profile` (Task 4), the i18n keys added in Task 8 (write the code against them now; Task 8 defines them). -- Produces: - - `ProfilesPanel` props: `{ profiles: Profile[]; activeProfileId: string; shots: Shot[]; loaded: boolean; onSelectProfile: (id: string) => void; onRenameProfile: (profile: Profile) => void; onRemoveProfile: (id: string) => void; headerAction?: ReactNode }` - - `ProfileNameDialog` props: `{ mode: 'add' | 'rename'; name: string; onChange: (name: string) => void; onConfirm: () => void; onCancel: () => void }` - - `PanelView` gains `'profiles'` and loses `'players'`. - -One dialog serves both modes because they differ only in title, confirm label, and initial value — two components would be duplication. - -- [ ] **Step 1: Write the failing tests** - -Create `ui/src/components/panel/ProfilesPanel.test.tsx`: - -```tsx -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { ProfilesPanel } from './ProfilesPanel'; -import type { Profile } from '../../types/profile'; -import type { Shot } from '../../types/shot'; - -const profile = (id: string, name: string): Profile => ({ - id, - name, - created_at: '2026-08-27T10:00:00Z', - settings: {}, -}); - -const shot = (profileId: string): Shot => ({ profile_id: profileId, ball_speed_mph: 100 }) as Shot; - -const baseProps = { - profiles: [profile('aaa', 'Home'), profile('bbb', 'Range')], - activeProfileId: 'aaa', - shots: [shot('aaa'), shot('aaa'), shot('bbb')], - loaded: true, - onSelectProfile: vi.fn(), - onRenameProfile: vi.fn(), - onRemoveProfile: vi.fn(), -}; - -describe('ProfilesPanel', () => { - it('renders every profile with its shot count', () => { - render(); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('2 shots')).toBeInTheDocument(); - expect(screen.getByText('1 shot')).toBeInTheDocument(); - }); - - it('selects a profile by id, not by name', () => { - const onSelectProfile = vi.fn(); - render(); - - fireEvent.click(screen.getByText('Range')); - - expect(onSelectProfile).toHaveBeenCalledWith('bbb'); - }); - - it('hides remove on the active profile', () => { - render(); - - expect(screen.queryByLabelText('Remove Home')).not.toBeInTheDocument(); - expect(screen.getByLabelText('Remove Range')).toBeInTheDocument(); - }); - - it('hides remove entirely when only one profile exists', () => { - render(); - - expect(screen.queryByLabelText('Remove Home')).not.toBeInTheDocument(); - }); - - it('offers rename for every profile, including the active one', () => { - const onRenameProfile = vi.fn(); - render(); - - fireEvent.click(screen.getByLabelText('Rename Home')); - - expect(onRenameProfile).toHaveBeenCalledWith(baseProps.profiles[0]); - }); - - it('shows a skeleton until the roster arrives', () => { - render(); - - expect(screen.getByRole('region', { name: 'Profiles' })).toHaveAttribute('aria-busy', 'true'); - expect(screen.queryByText('Home')).not.toBeInTheDocument(); - }); -}); -``` - -Create `ui/src/components/panel/ProfileNameDialog.test.tsx`: - -```tsx -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { ProfileNameDialog } from './ProfileNameDialog'; - -const baseProps = { - mode: 'add' as const, - name: '', - onChange: vi.fn(), - onConfirm: vi.fn(), - onCancel: vi.fn(), -}; - -describe('ProfileNameDialog', () => { - it('titles itself for the add mode', () => { - render(); - - expect(screen.getByRole('dialog', { name: 'Add profile' })).toBeInTheDocument(); - }); - - it('titles itself for the rename mode', () => { - render(); - - expect(screen.getByRole('dialog', { name: 'Rename profile' })).toBeInTheDocument(); - }); - - it('disables confirm for a blank name', () => { - render(); - - expect(screen.getByRole('button', { name: 'Add profile' })).toBeDisabled(); - }); - - it('confirms on Enter when the name is usable', () => { - const onConfirm = vi.fn(); - render(); - - fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }); - - expect(onConfirm).toHaveBeenCalled(); - }); - - it('does not confirm on Enter when the name is blank', () => { - const onConfirm = vi.fn(); - render(); - - fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }); - - expect(onConfirm).not.toHaveBeenCalled(); - }); - - it('caps the name at 40 characters', () => { - render(); - - expect(screen.getByRole('textbox')).toHaveAttribute('maxLength', '40'); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd ui && npx vitest run src/components/panel/ProfilesPanel.test.tsx src/components/panel/ProfileNameDialog.test.tsx` -Expected: FAIL — cannot resolve `./ProfilesPanel` - -- [ ] **Step 3: Create the dialog** - -Create `ui/src/components/panel/ProfileNameDialog.tsx`: - -```tsx -import { PanelAction } from './PanelAction'; -import { useI18n } from '../../i18n/useI18n'; - -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 canConfirm = Boolean(name.trim()); - const title = mode === 'add' ? t('menu.addProfile') : t('menu.renameProfile'); - - return ( -
-
- ); -} -``` - -- [ ] **Step 4: Create the panel** - -Create `ui/src/components/panel/ProfilesPanel.tsx`: - -```tsx -import { useMemo, useRef, type ReactNode } from 'react'; -import type { Profile } from '../../types/profile'; -import type { Shot } from '../../types/shot'; -import { filterShotsByProfile } 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); - // The active profile can never be removed: deleting the profile whose shots - // are on screen is a trap, and the server refuses it too. - const canRemove = profiles.length > 1; - const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? null; - const shotCounts = useMemo(() => { - const counts: Record = {}; - for (const profile of profiles) { - counts[profile.id] = filterShotsByProfile(shots, profile.id).length; - } - return counts; - }, [profiles, shots]); - - return ( -
- -
- {!loaded ? ( - -
- ); -} -``` - -- [ ] **Step 5: Update views, barrel, and CSS** - -In `ui/src/components/panel/views.ts`, change the `PanelView` union member `'players'` to `'profiles'` and the `PANEL_VIEWS` entry `{ id: 'players', label: 'Players' }` to `{ id: 'profiles', label: 'Profiles' }`. - -In `ui/src/components/panel/index.ts`, replace the `PlayersPanel` and `AddPlayerDialog` exports with `ProfilesPanel` and `ProfileNameDialog`. - -In `ui/src/components/panel/panel.css`, rename every `players-panel__*` selector to `profiles-panel__*` and every `add-player-modal*` selector to `profile-name-modal*`. Then add rules for the two new elements, matching the existing `profiles-panel__remove` styling for the rename button and a neutral placeholder block for the skeleton: - -```css -.profiles-panel__skeleton { - min-height: 6rem; - border-radius: var(--radius-md, 0.75rem); - background: var(--surface-2, rgba(255, 255, 255, 0.06)); -} -``` - -Position `.profiles-panel__rename` opposite `.profiles-panel__remove` on the card wrap (mirror the existing `remove` rule's absolute positioning, swapping its horizontal offset to the other side). Match the existing file's custom-property names rather than the placeholders above if they differ. - -- [ ] **Step 6: Delete the superseded components** - -```bash -git rm ui/src/components/panel/PlayersPanel.tsx ui/src/components/panel/PlayersPanel.test.tsx \ - ui/src/components/panel/AddPlayerDialog.tsx ui/src/components/panel/AddPlayerDialog.test.tsx -``` - -- [ ] **Step 7: Run the component tests** - -Run: `cd ui && npx vitest run src/components/panel/ProfilesPanel.test.tsx src/components/panel/ProfileNameDialog.test.tsx` -Expected: PASS once Task 8's i18n keys exist. If run before Task 8, the i18n lookups return the raw key and these assertions fail on copy — in that case do Task 8 first, then return here. (The plan orders i18n later only because it is mechanical; either order works.) - -- [ ] **Step 8: Stage (do not commit)** - -```bash -git add ui/src/components/panel/ -``` - ---- - -## Task 7: App wiring - -Delete the reconciliation code and drive everything from the mirror. - -**Files:** -- Modify: `ui/src/App.tsx:8,12,22,30,38,75-84,118-119,131-139,148-154,182-199,226-230,254,299-309` and the `currentView === 'players'` branch -- Modify: `ui/src/components/panel/{LivePanel,ShotsPanel,StatsPanel,MenuSheet,ClearSessionDialog,PanelFooter,liveMetrics}.tsx|.ts` — rename the `playerName` props and any `player` copy references -- Test: `ui/src/App.test.tsx` and the panel tests above - -**Interfaces:** -- Consumes: `useProfileStore`, `socketService.*Profile*` (Task 5); `ProfilesPanel`, `ProfileNameDialog` (Task 6); `filterShotsByProfile` (Task 4). -- Produces: no new exports. Component props renamed: `LivePanel.playerName` → `LivePanel.profileName`, and the same for any other panel taking a player name for display. - -- [ ] **Step 1: Write the failing test** - -Add to `ui/src/App.test.tsx`: - -```tsx -describe('profile roster', () => { - it('renders the roster once the server snapshot arrives', async () => { - useProfileStore.setState({ - profiles: [ - { id: 'aaa', name: 'Home', created_at: '2026-08-27T10:00:00Z', settings: {} }, - { id: 'bbb', name: 'Range', created_at: '2026-08-27T10:00:00Z', settings: {} }, - ], - activeProfileId: 'aaa', - loaded: true, - }); - - render(); - fireEvent.click(screen.getByRole('button', { name: 'Profiles' })); - - expect(await screen.findByText('Range')).toBeInTheDocument(); - }); - - it('emits set_active_profile with the id when a profile is picked', async () => { - const setActiveProfile = vi.spyOn(socketService, 'setActiveProfile'); - useProfileStore.setState({ - profiles: [ - { id: 'aaa', name: 'Home', created_at: '2026-08-27T10:00:00Z', settings: {} }, - { id: 'bbb', name: 'Range', created_at: '2026-08-27T10:00:00Z', settings: {} }, - ], - activeProfileId: 'aaa', - loaded: true, - }); - - render(); - fireEvent.click(screen.getByRole('button', { name: 'Profiles' })); - fireEvent.click(await screen.findByText('Range')); - - expect(setActiveProfile).toHaveBeenCalledWith('bbb'); - }); -}); -``` - -Match the existing `App.test.tsx` conventions for rendering and for reaching a panel — read the file's existing tests and reuse their navigation helper rather than assuming the footer button's accessible name. - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `cd ui && npx vitest run src/App.test.tsx` -Expected: FAIL — `usePlayerStore` import error or missing `Profiles` tab - -- [ ] **Step 3: Rewire App.tsx** - -Replace the imports on lines 8, 12, 22, 30, 38: - -```tsx -import { useProfileStore } from './stores/useProfileStore'; -``` -(delete the `shouldEchoSelectionToServer` import entirely) -```tsx - ProfileNameDialog, - ProfilesPanel, -``` -```tsx -import { filterShotsByProfile } from './types/shot'; -import type { Profile } from './types/profile'; -``` - -Replace the store selector (lines 75-84) with: - -```tsx - const { profiles, activeProfileId, profilesLoaded } = useProfileStore( - useShallow((state) => ({ - profiles: state.profiles, - activeProfileId: state.activeProfileId, - profilesLoaded: state.loaded, - })) - ); - const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? null; - const activeProfileName = activeProfile?.name ?? ''; -``` -(delete the `serverPlayerName` line) - -Replace the dialog state (lines 118-119) with: - -```tsx - const [profileDialog, setProfileDialog] = useState<{ mode: 'add' | 'rename'; target: Profile | null } | null>(null); - const [profileDialogName, setProfileDialogName] = useState(''); -``` - -**Delete lines 131-139 entirely** — the `appliedServerPlayer` reconciliation. The server is the only source now, so there is nothing to reconcile. Leave the `appliedServerClub` block above it alone; club still comes from the simulator. - -**Delete lines 148-154 entirely** — the echo-on-connect effect, along with its comment. `socketService` requests `get_profiles` on connect instead. - -Replace the handlers (lines 182-199) with: - -```tsx - const handleSelectProfile = (profileId: string) => { - socketService.setActiveProfile(profileId); - setCurrentView('live'); - }; - - 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 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(); - }; -``` - -Replace the shot scoping (lines 226-230): - -```tsx - const profileShots = filterShotsByProfile(shots, activeProfileId); - const profileLatestShot = profileShots[profileShots.length - 1] ?? null; - const profileIsNewShot = Boolean( - isNewShot && latestShot && profileLatestShot && latestShot.timestamp === profileLatestShot.timestamp - ); -``` - -Then rename every downstream use of `playerShots` / `playerLatestShot` / `playerIsNewShot` / `selectedPlayer` to the `profile*` equivalents (`activeProfileName` where a display name is wanted), replace the `addPlayerAction` definition (line 254) with: - -```tsx - const addProfileAction = {t('menu.addProfile')}; -``` - -replace the `currentView === 'players'` branch with a `'profiles'` branch rendering: - -```tsx - -``` - -and replace the `AddPlayerDialog` render with: - -```tsx - {profileDialog ? ( - - ) : null} -``` - -Finally, update the `clearSession` call site to pass `activeProfileId` instead of the selected player name, and the `LivePanel` `playerName={selectedPlayer}` prop to `profileName={activeProfileName}`. - -- [ ] **Step 4: Rename the downstream panel props** - -Run `grep -rn "playerName\|player_name\|selectedPlayer" ui/src --include=*.tsx --include=*.ts` and rename each remaining occurrence: props named `playerName` become `profileName`, and any `filterSwingSpeedShots({ playerName })` call becomes `{ profileId }` passing `activeProfileId`. Update each component's test alongside it. - -- [ ] **Step 5: Typecheck and test** - -Run: -```bash -cd ui && npm run lint && npx tsc --noEmit && npm test -``` -Expected: clean lint, no type errors, all tests pass. `tsc --noEmit` is the real gate here — it finds every consumer the greps missed. - -- [ ] **Step 6: Stage (do not commit)** - -```bash -git add ui/src -``` - ---- - -## Task 8: Locale strings - -Rename the keys and translate the new copy properly. Leaving "Players" in the Spanish file would be a regression, not a rename. - -**Files:** -- Modify: `ui/src/i18n/en.ts`, `ui/src/i18n/es.ts`, `ui/src/i18n/fr.ts`, `ui/src/i18n/pt.ts` -- Test: `ui/src/i18n/i18n.test.ts` (existing key-parity check — no new test needed, it fails automatically on a missed key) - -**Interfaces:** -- Consumes: nothing. -- Produces: the keys `nav.profiles`, `profiles.rosterAria`, `profiles.shots`, `profiles.shot`, `profiles.namePlaceholder`, `menu.profile`, `menu.addProfile`, `menu.renameProfile`, `menu.renameProfileNamed`, `menu.removeProfile`, `shots.colProfile`, `metric.profileImplement`. The keys `nav.players`, `players.*`, `menu.player`, `menu.addPlayer`, `menu.removePlayer`, `shots.colPlayer`, `metric.playerImplement` are removed. - -- [ ] **Step 1: Run the parity test to see it fail** - -Run: `cd ui && npx vitest run src/i18n/i18n.test.ts` -Expected: FAIL — keys referenced by the new components are missing from every locale. - -- [ ] **Step 2: Update en.ts** - -```ts - 'nav.profiles': 'Profiles', - 'metric.profileImplement': 'profile + implement', - 'shots.colProfile': 'Profile', - 'profiles.rosterAria': 'Profiles', - 'profiles.shots': '{count} shots', - 'profiles.shot': '{count} shot', - 'profiles.namePlaceholder': 'Name', - 'menu.profile': 'Profile', - 'menu.addProfile': 'Add profile', - 'menu.renameProfile': 'Rename profile', - 'menu.renameProfileNamed': 'Rename {name}', - 'menu.removeProfile': 'Remove {name}', - 'clearSession.detail': "This removes {name}'s shots. Other profiles are kept.", -``` - -- [ ] **Step 3: Update es.ts** - -```ts - 'nav.profiles': 'Perfiles', - 'metric.profileImplement': 'perfil + implemento', - 'shots.colProfile': 'Perfil', - 'profiles.rosterAria': 'Perfiles', - 'profiles.shots': '{count} golpes', - 'profiles.shot': '{count} golpe', - 'profiles.namePlaceholder': 'Nombre', - 'menu.profile': 'Perfil', - 'menu.addProfile': 'Añadir perfil', - 'menu.renameProfile': 'Renombrar perfil', - 'menu.renameProfileNamed': 'Renombrar {name}', - 'menu.removeProfile': 'Eliminar {name}', - 'clearSession.detail': 'Esto elimina los golpes de {name}. Los demás perfiles se conservan.', -``` - -- [ ] **Step 4: Update fr.ts** - -```ts - 'nav.profiles': 'Profils', - 'metric.profileImplement': 'profil + accessoire', - 'shots.colProfile': 'Profil', - 'profiles.rosterAria': 'Profils', - 'profiles.shots': '{count} coups', - 'profiles.shot': '{count} coup', - 'profiles.namePlaceholder': 'Nom', - 'menu.profile': 'Profil', - 'menu.addProfile': 'Ajouter un profil', - 'menu.renameProfile': 'Renommer le profil', - 'menu.renameProfileNamed': 'Renommer {name}', - 'menu.removeProfile': 'Supprimer {name}', - 'clearSession.detail': 'Cela supprime les coups de {name}. Les autres profils sont conservés.', -``` - -- [ ] **Step 5: Update pt.ts** - -```ts - 'nav.profiles': 'Perfis', - 'metric.profileImplement': 'perfil + implemento', - 'shots.colProfile': 'Perfil', - 'profiles.rosterAria': 'Perfis', - 'profiles.shots': '{count} tacadas', - 'profiles.shot': '{count} tacada', - 'profiles.namePlaceholder': 'Nome', - 'menu.profile': 'Perfil', - 'menu.addProfile': 'Adicionar perfil', - 'menu.renameProfile': 'Renomear perfil', - 'menu.renameProfileNamed': 'Renomear {name}', - 'menu.removeProfile': 'Remover {name}', - 'clearSession.detail': 'Isto remove as tacadas de {name}. Os outros perfis são mantidos.', -``` - -Match each file's existing plural/formatting conventions — if a locale file already handles plurals differently from `en`, follow its pattern rather than the literal strings above. - -- [ ] **Step 6: Confirm no player keys survive** - -Run: `grep -rn "player" -i ui/src/i18n/` -Expected: no output. - -- [ ] **Step 7: Run the i18n and component tests** - -Run: `cd ui && npx vitest run src/i18n src/components/panel` -Expected: PASS - -- [ ] **Step 8: Stage (do not commit)** - -```bash -git add ui/src/i18n -``` - ---- - -## Task 9: Mock server - -`scripts/start-kiosk.sh --mock` is the documented dev path, so the mock must speak the same protocol. - -**Files:** -- Modify: `ui/mock-server/session.ts:61,89,123-126,135,142-145`, `ui/mock-server/handlers.ts:81-100`, `ui/mock-server/shotGenerator.ts:114,146` - -**Interfaces:** -- Consumes: the socket contract from Task 3. -- Produces: `MockSession.profiles: Profile[]`, `.activeProfileId: string`, `.snapshot()`, `.addProfile(name)`, `.renameProfile(id, name)`, `.removeProfile(id)`, `.setActiveProfile(id)`, `.clearProfile(id)`; `generateShot({ club, profileId, profileName })`. - -- [ ] **Step 1: Replace the session's player state** - -In `ui/mock-server/session.ts`, replace the `playerName = 'Player 1';` field with an in-memory roster mirroring `ProfileStore`'s invariants: - -```ts - profiles: Profile[] = [ - { id: 'mock-profile-1', name: 'Profile 1', created_at: '2026-01-01T00:00:00Z', settings: {} }, - ]; - activeProfileId = 'mock-profile-1'; - private nextProfileNumber = 2; - - 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. - if (profileId === this.activeProfileId || this.profiles.length <= 1) 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); - } -``` - -Import `Profile` from `../src/types/profile` (match the file's existing import style for shared types). Delete the old `setPlayer` and `clearPlayer` methods. In the session-state payload (line 89) delete the `player_name` entry. In `simulateShot` (line 135), pass the active profile: - -```ts - const shot = generateShot({ - club: this.club, - profileId: this.activeProfile.id, - profileName: this.activeProfile.name, - }); -``` - -- [ ] **Step 2: Replace the mock socket handlers** - -In `ui/mock-server/handlers.ts`, replace the `set_player` and `clear_session` handlers (lines 81-100) with: - -```ts - 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 }); - }); -``` - -Also emit `profiles` on connection, next to whatever the file already emits on `connection`. - -- [ ] **Step 3: Update the shot generator** - -In `ui/mock-server/shotGenerator.ts`, replace the `playerName: string;` option (line 114) with `profileId: string; profileName: string;` and the `player_name: options.playerName,` field (line 146) with: - -```ts - profile_id: options.profileId, - profile_name: options.profileName, -``` - -- [ ] **Step 4: Verify the mock manually** - -Run: `cd ui && npm run dev` (with the mock server per the repo's usual dev command), open the UI, and confirm: the Profiles tab lists `Profile 1`; adding a profile makes it active; renaming updates the card; the ✕ is absent on the active card; simulating a shot attributes it to the active profile; and clearing removes only that profile's shots. - -- [ ] **Step 5: Lint and stage (do not commit)** - -```bash -cd ui && npm run lint -git add ui/mock-server -``` - ---- - -## Task 10: End-to-end tests, docs, and full verification - -**Files:** -- Modify: `ui/tests/e2e/app.spec.ts`, `ui/tests/e2e/helpers.ts`, `ui/tests/e2e/fixtures/camera-replay.tsx`, `ui/tests/e2e/camera-replay.spec.ts` -- Modify: `ui/README.md`, `docs/CHANGELOG.md` - -**Interfaces:** -- Consumes: everything above. -- Produces: no code exports. - -- [ ] **Step 1: Update the e2e helpers and specs** - -Run `grep -rn "player" -i ui/tests/e2e/` and update each hit: the `Players` tab becomes `Profiles`, `player_name` in any fixture shot becomes `profile_id` / `profile_name`, and any helper that seeds a player seeds a profile via the socket events instead. - -- [ ] **Step 2: Add an e2e case for rename** - -Add to `ui/tests/e2e/app.spec.ts`, following the file's existing test structure and locators: - -```ts -test('renaming a profile keeps its shots', async ({ page }) => { - await page.goto('/'); - 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('button', { name: 'Rename profile' }).last().click(); - - await expect(page.getByText('Range')).toBeVisible(); -}); -``` - -- [ ] **Step 3: Run the e2e suite** - -Run: `cd ui && npx playwright test` -Expected: PASS - -- [ ] **Step 4: Update the docs** - -In `ui/README.md`, replace the player references with profiles and document the socket contract: the `profiles` snapshot event and the five client→server mutations. - -In `docs/CHANGELOG.md`, add an entry under the current unreleased section: - -```markdown -- **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`. Profiles can be renamed without orphaning - their shots. 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. -``` - -- [ ] **Step 5: Full verification** - -Run every gate: - -```bash -uv run pytest tests/ -v -uv run pylint src/openflight/ --fail-under=9 -uv run ruff check src/openflight/ -uv run ruff format --check src/openflight/ -cd ui && npm run lint && npx tsc --noEmit && npm test && npx playwright test -``` - -Expected: all pass, pylint ≥ 9.0. - -- [ ] **Step 6: Final sweep for stragglers** - -Run: -```bash -grep -rn "player" -i src/openflight tests ui/src ui/mock-server ui/tests \ - | grep -v "sim/\|gspro/\|test_sim\|test_gspro\|log_sim_player\|sim_player" -``` -Expected: no output. Every remaining hit must be a simulator-protocol reference; anything else is a missed rename. - -- [ ] **Step 7: Stage (do not commit)** - -```bash -git add ui/tests ui/README.md docs/CHANGELOG.md -``` - -Report the full verification output to the repo owner and ask whether to commit. - ---- - -## Self-Review - -**Spec coverage:** Data model → Task 1. Store file, invariants, atomic writes → Task 1. Shot attribution (`profile_id` + `profile_name`, exact match) → Tasks 2 and 4. Socket contract (snapshot event, five mutations, rejected-input behaviour) → Tasks 3, 5, 9. Server changes (global removed, helpers collapsed, `session_state` stripped) → Task 3. UI store with no localStorage → Task 5. `playerSocketSync` deletion → Task 5. Reconciliation deletion → Task 7. Delete-active forbidden → Tasks 1, 3, 6, 7. Rename included → Tasks 1, 3, 6, 7, 10. Component and CSS renames → Task 6. Locales with real translations → Task 8. Mock server → Task 9. Clean break, no migration → no task reads old data anywhere. Testing section → each task's tests plus Task 10. `sim`/`gspro` untouched → Global Constraints and the Task 10 sweep. - -**Type consistency:** `profile_id` / `profile_name` are used identically in Python (Tasks 2, 3) and TypeScript (Tasks 4-9). `ProfileStore` method names in Task 1's Interfaces match every call site in Task 3. `ProfilesSnapshot` (Task 4) matches `snapshot()`'s output (Task 1) and the mock's `snapshot()` (Task 9). `useProfileStore`'s `loaded` flag (Task 5) matches `ProfilesPanel`'s `loaded` prop (Task 6) and `profilesLoaded` in `App.tsx` (Task 7). `socketService` method names (Task 5) match the handler event names (Task 3) and the mock's listeners (Task 9). i18n keys used in Task 6's components are all defined in Task 8. - -**Known ordering wrinkle:** Task 6's component tests assert on English copy defined in Task 8. Either do Task 8 before Task 6, or expect those two assertions to fail until Task 8 lands. Flagged inline in Task 6, Step 7. diff --git a/docs/superpowers/specs/2026-08-27-profiles-design.md b/docs/superpowers/specs/2026-08-27-profiles-design.md deleted file mode 100644 index 0065f8408..000000000 --- a/docs/superpowers/specs/2026-08-27-profiles-design.md +++ /dev/null @@ -1,290 +0,0 @@ -# Profiles (replacing Players) - -**Date:** 2026-08-27 -**Status:** Approved design, ready for implementation planning - -## Problem - -Today a "player" is a bare name string. `usePlayerStore` keeps a list of names and a -selected name in browser `localStorage`; the socket contract is `set_player` → -`player_changed` carrying `player_name`; the server holds a single global -`current_player_name`; and every `Shot` and swing-speed event is stamped with -`player_name` and filtered by case-insensitive name match. - -Three problems follow from that model: - -1. **A name is not an identity.** Renaming is impossible without orphaning every shot - already recorded under the old name. -2. **The roster is browser-local.** Nothing server-side can read it, so no future feature - can attach settings to a profile. A reflashed kiosk or a second browser loses the roster. -3. **Two sources of truth race.** `session_state.player_name` (a connect-time snapshot) and - `player_changed` (a live update) can disagree. `ui/src/services/playerSocketSync.ts` - exists solely to referee that race, and `App.tsx:131-153` runs a reconciliation dance - for the same reason. - -"Player" is also the wrong word. The thing shots are attributed to may be a person *or* a -place — a range bay, a home net, a course — and the current noun excludes half of that. - -## Goals - -- Replace "player" with "profile" across the UI and socket layer. -- Give each profile a stable id, so renaming never orphans shots. -- Persist profiles server-side, so later features can attach settings to them. -- Collapse the two sources of truth into one, deleting the reconciliation code. - -## Non-goals - -- Defining any specific profile setting. `settings` is an open dict; later features claim - keys in it. No setting is specified or consumed by this work. -- Renaming anything in `src/openflight/sim/` or `src/openflight/gspro/`. `PlayerState` - and GSPro's `Player` fields are an external wire protocol, not our terminology. -- Cloud sync of profiles. -- Migrating existing player data. This is a clean break (see Migration). - -## Data model - -A profile record is untyped — a profile is just a name, whether it denotes a person or a -place: - -```json -{ - "id": "a3f2c1d0e5b6478f9a0b1c2d3e4f5061", - "name": "Home Range", - "created_at": "2026-08-27T10:14:03Z", - "settings": {} -} -``` - -- `id` — uuid4 hex, generated, never derived from the name. Renames are free. -- `name` — trimmed, capped at 40 characters. **Not unique**; `id` is the key, so two - profiles named "Range" are legal. -- `created_at` — ISO 8601 UTC. -- `settings` — an open dict the server round-trips untouched. This is the extension point - for later features; nothing in this work reads or writes it. - -### Store - -File: `~/.config/openflight/profiles.json` (the established config dir, alongside -`cloud/config.py`'s `cloud.json` and the camera exposure state). - -```json -{ "profiles": [ ... ], "active_profile_id": "a3f2..." } -``` - -New module `src/openflight/profiles.py` owning a `ProfileStore` class: - -| Method | Behaviour | -|---|---| -| `list()` | All profiles, insertion-ordered | -| `get_active()` | The active profile record | -| `add(name)` | Append and make active; returns the new record | -| `rename(id, name)` | Change `name` in place; `id` and shot attribution unaffected | -| `remove(id)` | Delete; **rejected** if `id` is active or the last profile | -| `set_active(id)` | Change `active_profile_id`; rejected if `id` is unknown | - -Persistence details: - -- **Atomic writes** — write to a temp file in the same directory, then `os.replace`. A - power cut on the Pi mid-write must not truncate the roster. -- **Corrupt or missing file** — log and fall back to a freshly seeded store containing one - default profile. Never raise into server startup. -- **Concurrency** — a single in-process lock. This is a kiosk with one writer; no file - locking. -- **Roster cap** — 12 profiles, matching today's limit. - -### Invariants - -- At least one profile always exists. -- `active_profile_id` always names a live profile. -- Rejected mutations change nothing and are answered with the unchanged state. - -### Shot attribution - -`Shot` and `SwingSpeedEvent` gain `profile_id` and `profile_name` and drop `player_name`. - -- `profile_id` is the filter key, matched **exactly** — no case folding. The existing - `normalizePlayerName` (`ui/src/types/shot.ts:157`) and `_normalize_player_name` / - `_player_matches` (`server.py:2144-2152`) are deleted outright. Case-insensitive name - matching is a bug source: two profiles differing only in case currently collide. -- `profile_name` is a denormalized snapshot taken at stamp time, so session JSONL stays - human-readable without joining against `profiles.json`. It is never used for filtering. - -## Socket contract - -### Server → client - -One authoritative snapshot event, emitted on connect and after **every** mutation -(including rejected ones): - -``` -"profiles" { profiles: [{id, name, created_at, settings}], active_profile_id } -``` - -Roster and selection always arrive together and therefore cannot disagree. `session_state` -drops `player_name` and carries no selection at all — this is what removes the race. -`ui/src/services/playerSocketSync.ts` and its test are **deleted**, not renamed. - -`session_cleared` becomes `{ profile_id, shots }`. - -### Client → server - -``` -"set_active_profile" { profile_id } -"add_profile" { name } → adds and makes active -"rename_profile" { profile_id, name } -"remove_profile" { profile_id } -"clear_session" { profile_id } -``` - -The four roster mutations (`set_active_profile`, `add_profile`, `rename_profile`, -`remove_profile`) each end by broadcasting the `profiles` snapshot. `clear_session` does not — -it mutates shots, not the roster — and answers with `session_cleared` instead. - -### Rejected input - -Unknown `profile_id`, blank name, removing the active profile, and removing the last -profile are all answered with the unchanged snapshot rather than a silent default or an -error event. A confused or stale client self-heals on the next round trip. - -Note that the server rejecting `remove_profile` on the active id is the backstop for the -UI rule below — the invariant holds even if a stale client asks. - -## Server changes - -- `current_player_name` (`server.py:90`) is replaced by the `ProfileStore`. -- `shot.player_name = current_player_name` (`server.py:3125`) becomes `profile_id` / - `profile_name` stamped from `store.get_active()`. Same for the swing-speed event path - (`server.py:3782`). -- `_normalize_player_name`, `_player_matches`, and `_clear_player_rows` - (`server.py:2144-2187`) collapse into `_clear_profile_rows(profile_id)` doing an exact - id match. -- `handle_set_player` (`server.py:2116`) is replaced by the five handlers above. -- `session_logger` field names follow the `Shot` rename. -- `sim/` and `gspro/` are untouched. - -## UI changes - -### Store - -`usePlayerStore` → `useProfileStore`, and it **stops being a source of truth**. It holds -`profiles`, `activeProfileId`, and actions that emit socket events; the `profiles` snapshot -handler replaces state wholesale. - -**No `localStorage`.** The server already tracks the active profile globally, exactly as it -does today with `current_player_name`, so a browser-side copy is a second truth with -nothing to add. Before the socket connects the roster renders a disabled skeleton rather -than a guessed default. Actions no-op while disconnected. - -This deletes `App.tsx:131-153` entirely: the `appliedServerPlayer` tracking, the -echo-on-connect, and the comment explaining why it must not re-emit on change. - -### Behaviour - -- **Deleting the active profile is not allowed.** The ✕ stays hidden on the active card - and the server rejects it. Deleting the profile whose shots are on screen is a usability - trap, not a capability. -- **Renaming is added.** Stable ids make it safe for the first time. The add dialog is - reused with an initial value, wired to `rename_profile`. - -### Renames - -| From | To | -|---|---| -| `PlayersPanel` | `ProfilesPanel` | -| `AddPlayerDialog` | `AddProfileDialog` | -| `players-panel__*` CSS | `profiles-panel__*` | -| `'players'` panel view / tab | `'profiles'` | -| `filterShotsByPlayer` / `excludeShotsByPlayer` | `filterShotsByProfile` / `excludeShotsByProfile`, keyed on `profileId` | -| `SwingSpeedStatsFilter.playerName` | `SwingSpeedStatsFilter.profileId` | -| `socketService.setPlayer` / `clearSession(playerName)` | `setActiveProfile` / `clearSession(profileId)` | - -All four locale files (`en`, `es`, `fr`, `pt`) get the key renames **plus real -translations** — Perfiles / Profils / Perfis. Affected keys: `nav.players`, -`players.rosterAria`, `players.shots`, `players.shot`, `players.namePlaceholder`, -`menu.player`, `menu.addPlayer`, `menu.removePlayer`, `shots.colPlayer`, -`metric.playerImplement`, `clearSession.detail`. - -The mock server (`ui/mock-server/`) implements the same profile events over an in-memory -store, so `--mock` keeps working. - -## Migration - -**Clean break.** No migration of existing player data. - -- On first run the server seeds `profiles.json` with a single profile named `Profile 1`. -- Existing browser `localStorage` player rosters (`openflight-players`, - `openflight-selected-player`) are simply abandoned in place on existing kiosks, not actively - removed. Adding removal code would itself be the migration cruft the clean break set out to - avoid. -- Old session JSONL entries keep their `player_name` field and are simply not filterable by - profile. Nothing reads them at runtime. -- The socket exposes only the new events. No dual-emit compatibility window, so there is no - cruft to remember to delete. - -## Testing - -### Python - -New `tests/test_profiles.py` — `ProfileStore` directly: - -- Missing file seeds a default profile. -- Corrupt JSON falls back to a seeded default rather than raising. -- Atomic write leaves no truncated file on failure. -- Name trimmed and capped at 40 characters. -- Blank name rejected. -- Duplicate names allowed. -- `remove` of the active id rejected. -- `remove` of the last profile rejected. -- `set_active` / `rename` / `remove` with an unknown id are no-ops. -- `settings` round-trips byte-identical through save/load — the guarantee later features - depend on. -- `active_profile_id` always names a live profile after any operation. - -Additions to `tests/test_server.py`: - -- Every mutation handler broadcasts the `profiles` snapshot. -- A rejected mutation broadcasts the **unchanged** snapshot. -- Shots stamp `profile_id` and `profile_name` from the active profile. -- Swing-speed events stamp the same fields. -- `clear_session` scopes by exact id, including two profiles whose names differ only in - case — the case the old code got wrong. -- `session_state` carries no selection field. - -### UI (vitest) - -- `useProfileStore.test.ts` — snapshot replaces state wholesale; actions emit the right - events with the right payloads; actions no-op while disconnected. -- `shot.test.ts` — id-keyed filtering, including shots with a missing or unknown - `profile_id` falling out of every profile. -- `ProfilesPanel.test.tsx` — roster render, shot counts, select, rename, ✕ hidden on the - active card. -- `AddProfileDialog.test.tsx` — add and rename modes. -- `i18n.test.ts` — existing key-parity check catches any missed locale key. -- `App.test.tsx` — reconciliation tests deleted; one added for the pre-connect skeleton. -- E2E `app.spec.ts` and `helpers.ts` updated for the new panel and tab. - -Implementation is test-first per the project's rules. - -### Verification - -``` -uv run pytest tests/ -v -uv run pylint src/openflight/ --fail-under=9 -uv run ruff check src/openflight/ && uv run ruff format --check src/openflight/ -cd ui && npm run lint && npm test -``` - -plus the Playwright e2e run. - -## Decisions and rationale - -| Decision | Rationale | -|---|---| -| Untyped profile (no `person`/`location` kind) | YAGNI. A name is a name; `settings` can differentiate later without a schema enum to maintain. | -| Server-owned JSON store | Later features attaching settings to profiles are mostly server-side concerns. A reflashed kiosk or second browser should not lose the roster. | -| Stamp both `profile_id` and `profile_name` | Id makes renames safe; the denormalized name keeps session JSONL readable without a join. | -| One `profiles` snapshot event | Roster and selection cannot disagree. Deletes the race `playerSocketSync.ts` was written to referee. Cost is the full roster on the wire per change — a rounding error at 12 small records on a LAN socket. | -| No `localStorage` | The server is already globally authoritative for the active profile. A browser copy is a second truth with nothing to add. | -| Delete-active forbidden | Discarding the shots currently on screen is a usability trap. | -| Rename included | It is the concrete payoff of stable ids and the reason to do this rather than a find-and-replace. | -| Clean break, no migration | Single-deployment DIY project; a compat window would be cruft with no consumer. | From e5b236a870725a4cf905e950cf67ba15f8aa28ca Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Thu, 27 Aug 2026 20:23:29 +0100 Subject: [PATCH 07/10] refactor(profiles): ensure settings are not mutated externally and enforce locking during payload operations --- src/openflight/profiles.py | 13 +++--- tests/test_profiles.py | 52 +++++++++++++++++++++++ ui/README.md | 7 +-- ui/src/components/panel/ProfilesPanel.tsx | 1 - 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/openflight/profiles.py b/src/openflight/profiles.py index e959f96a0..5c7876ea0 100644 --- a/src/openflight/profiles.py +++ b/src/openflight/profiles.py @@ -52,15 +52,14 @@ class Profile: def to_dict(self) -> dict: """Wire/disk representation. - ``settings`` is returned by reference, not copied: a caller that - holds onto it and mutates it later bypasses the store's lock and - ``save()`` path. + ``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": self.settings, + "settings": dict(self.settings), } @classmethod @@ -112,7 +111,8 @@ def get_active(self) -> Profile: def snapshot(self) -> dict: """The authoritative payload broadcast on the socket.""" - return self._payload() + with self._lock: + return self._payload() # -- mutations ----------------------------------------------------- @@ -167,7 +167,8 @@ def set_active(self, profile_id: Any) -> bool: def save(self) -> None: """Write the roster atomically. Never raises into a caller.""" - payload = self._payload() + 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) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index e0279da3a..6c5fb920e 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -7,6 +7,7 @@ from openflight.profiles import ( DEFAULT_PROFILE_NAME, MAX_PROFILES, + Profile, ProfileStore, ) @@ -274,6 +275,31 @@ def test_rename_preserves_settings(self, store_path): 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.""" @@ -313,3 +339,29 @@ def test_no_temp_files_left_behind(self, 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() diff --git a/ui/README.md b/ui/README.md index d6a31e67a..f905c3eb3 100644 --- a/ui/README.md +++ b/ui/README.md @@ -112,9 +112,10 @@ Client → server: - `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` (denormalized at capture time), -not a live reference — renaming a profile does not rewrite past shots' display -name. +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 diff --git a/ui/src/components/panel/ProfilesPanel.tsx b/ui/src/components/panel/ProfilesPanel.tsx index f6dd76528..c558697a3 100644 --- a/ui/src/components/panel/ProfilesPanel.tsx +++ b/ui/src/components/panel/ProfilesPanel.tsx @@ -1,7 +1,6 @@ import { useMemo, useRef, type ReactNode } from 'react'; import type { Profile } from '../../types/profile'; import type { Shot } from '../../types/shot'; -import { filterShotsByProfile } from '../../types/shot'; import { useDragScroll } from '../../hooks/useDragScroll'; import { useI18n } from '../../i18n/useI18n'; import { PanelHeader } from './PanelHeader'; From b31a23b894ea74690dade57a6dfcccf3e023c841 Mon Sep 17 00:00:00 2001 From: Cormac McGrath Date: Sat, 29 Aug 2026 16:24:03 +0100 Subject: [PATCH 08/10] refactor: remove .editorconfig and .gitattributes; enhance profile management in line with pr comments --- .editorconfig | 21 ---- .gitattributes | 25 ---- docs/CHANGELOG.md | 9 +- src/openflight/profiles.py | 17 ++- src/openflight/server.py | 33 +++++- tests/conftest.py | 7 ++ tests/test_e2e_profile_isolation.py | 41 +++++++ tests/test_profiles.py | 60 ++++++++++ tests/test_server.py | 60 ++++++++++ ui/.prettierrc | 3 +- ui/README.md | 3 +- ui/mock-server/session.ts | 4 +- ui/playwright.config.ts | 13 +++ .../components/panel/ProfilesPanel.test.tsx | 12 +- ui/src/components/panel/ProfilesPanel.tsx | 7 +- ui/src/components/panel/panel.css | 59 ++++++++++ ui/tests/e2e/app.spec.ts | 107 +++++++++++++++++- ui/tests/e2e/helpers.ts | 11 +- ui/tests/e2e/isolateProfilesPath.ts | 11 ++ 19 files changed, 435 insertions(+), 68 deletions(-) delete mode 100644 .editorconfig delete mode 100644 .gitattributes create mode 100644 tests/test_e2e_profile_isolation.py create mode 100644 ui/tests/e2e/isolateProfilesPath.ts diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 3c9990c4d..000000000 --- a/.editorconfig +++ /dev/null @@ -1,21 +0,0 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true -charset = utf-8 -trim_trailing_whitespace = true - -[*.md] -trim_trailing_whitespace = false - -[*.{js,jsx,ts,tsx,cjs,mjs,css,json,yml,yaml,html}] -indent_style = space -indent_size = 2 - -[*.py] -indent_style = space -indent_size = 4 - -[Makefile] -indent_style = tab diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 8a4a21f2b..000000000 --- a/.gitattributes +++ /dev/null @@ -1,25 +0,0 @@ -# Keep working-tree line endings as LF on every OS. Windows core.autocrlf -# otherwise checks out CRLF, which makes `prettier --check` fail locally -# while CI (Ubuntu) still passes. -* text=auto eol=lf - -*.png binary -*.jpg binary -*.jpeg binary -*.gif binary -*.webp binary -*.ico binary -*.woff binary -*.woff2 binary -*.ttf binary -*.eot binary -*.otf binary -*.zip binary -*.gz binary -*.pkl binary -*.mp4 binary -*.webm binary -*.pdf binary -*.bin binary -*.elf binary -*.hex binary diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2afd38648..1ed1c6551 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. @@ -19,8 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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`. Profiles can be renamed without orphaning - their shots. The socket exposes a single authoritative `profiles` snapshot plus + `~/.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 diff --git a/src/openflight/profiles.py b/src/openflight/profiles.py index 5c7876ea0..9aba0a004 100644 --- a/src/openflight/profiles.py +++ b/src/openflight/profiles.py @@ -24,11 +24,26 @@ 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: @@ -89,7 +104,7 @@ class ProfileStore: """ def __init__(self, path: Union[str, Path, None] = None): - self._path = Path(path).expanduser() if path else DEFAULT_PROFILES_PATH + 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] = [] diff --git a/src/openflight/server.py b/src/openflight/server.py index 2216209e6..2f0c859af 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -2410,8 +2410,12 @@ def handle_rename_profile(data=None): @socketio.on("remove_profile") def handle_remove_profile(data=None): - """Delete a profile. Refused for the active or the last one.""" - get_profile_store().remove(_payload_dict(data).get("profile_id")) + """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() @@ -2432,6 +2436,21 @@ def handle_set_training_implement(data): ) +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 _clear_profile_rows(profile_id: str) -> None: """Remove one profile's shots or swing-speed reps from the active monitor. @@ -5161,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( @@ -5582,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 @@ -5598,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/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..29c2feef0 --- /dev/null +++ b/tests/test_e2e_profile_isolation.py @@ -0,0 +1,41 @@ +"""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_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 index 6c5fb920e..7ebff05dd 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -1,14 +1,18 @@ """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, ) @@ -365,3 +369,59 @@ def observing_payload(): 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 5f7403805..3457d7379 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2460,6 +2460,66 @@ def test_remove_profile_refuses_the_active_one(self, store, 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") diff --git a/ui/.prettierrc b/ui/.prettierrc index 75b903ab4..da0c9bca5 100644 --- a/ui/.prettierrc +++ b/ui/.prettierrc @@ -3,6 +3,5 @@ "trailingComma": "es5", "tabWidth": 2, "semi": true, - "singleQuote": true, - "endOfLine": "lf" + "singleQuote": true } diff --git a/ui/README.md b/ui/README.md index f905c3eb3..9321b75ae 100644 --- a/ui/README.md +++ b/ui/README.md @@ -108,7 +108,8 @@ Client → server: - `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 or the last remaining profile. + 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. diff --git a/ui/mock-server/session.ts b/ui/mock-server/session.ts index cdabea8a8..03d61d0b4 100644 --- a/ui/mock-server/session.ts +++ b/ui/mock-server/session.ts @@ -116,8 +116,10 @@ export class MockSession { } removeProfile(profileId: unknown): void { - // Same refusals as the real store: never the active one, never the last. + // 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); } diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 8a94eac29..ff4595983 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,6 +12,17 @@ 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', fullyParallel: false, @@ -29,6 +41,7 @@ export default defineConfig({ url: `http://${HOST}:8080`, reuseExistingServer: !process.env.CI, cwd: fileURLToPath(new URL('..', import.meta.url)), + env: backendEnv(), }, { command: `npm run dev -- --host ${HOST} --port ${PORT} --mode test`, diff --git a/ui/src/components/panel/ProfilesPanel.test.tsx b/ui/src/components/panel/ProfilesPanel.test.tsx index f71f953ee..cf62eca3f 100644 --- a/ui/src/components/panel/ProfilesPanel.test.tsx +++ b/ui/src/components/panel/ProfilesPanel.test.tsx @@ -53,13 +53,19 @@ describe('ProfilesPanel', () => { expect(rangeCard).toContain('aria-pressed="false"'); }); - it('hides remove on the active profile, shows it on the rest', () => { - const html = render(); + 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')] }); @@ -74,7 +80,7 @@ describe('ProfilesPanel', () => { }); it('groups rename and remove in a right-aligned actions cluster', () => { - const html = render(); + const html = render({ shots: [shot('aaa')] }); const clusters = [...html.matchAll(/
[\s\S]*?<\/div>/g)].map( (match) => match[0] ); diff --git a/ui/src/components/panel/ProfilesPanel.tsx b/ui/src/components/panel/ProfilesPanel.tsx index c558697a3..6174781bc 100644 --- a/ui/src/components/panel/ProfilesPanel.tsx +++ b/ui/src/components/panel/ProfilesPanel.tsx @@ -31,8 +31,9 @@ export function ProfilesPanel({ const { t } = useI18n(); const rosterRef = useRef(null); const dragScroll = useDragScroll(rosterRef); - // The active profile can never be removed: deleting the profile whose shots - // are on screen is a trap, and the server refuses it too. + // 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(() => { @@ -88,7 +89,7 @@ export function ProfilesPanel({ > ✎ - {canRemove && !selected ? ( + {canRemove && !selected && count === 0 ? (