From 345b607a70941ff4fb71351a23ac8547df17850a Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Wed, 23 Sep 2026 15:02:17 -0400 Subject: [PATCH 1/3] docs(roadmap): correct the wire contract and record what has shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap has drifted from the code in ways that would mislead someone implementing from it. Phase 1 item 4 specified `set_player` / `player_changed`, and the contract reference listed both. Neither event exists on the server. Profiles replaced players: the server exposes get_profiles, set_active_profile, add_profile, rename_profile and remove_profile, answered by a `profiles` snapshot, and stamps every shot with profile_id / profile_name. Anyone building Phase 1 item 4 from this document would have written a client against an API that was never there. The graceful-shutdown row said it "prevents yanking power on a live Pi". POST /api/shutdown stops the OpenFlight server process — server.py calls os._exit(0) — and leaves the Pi powered; no poweroff path exists in either server.py or start-kiosk.sh. Describing it as a power-safety measure invites exactly the wrong conclusion, so it is now worded as stopping the server, matching the UI that shipped in #26. Also: - Adds a status column so each item says whether it shipped and in which PR, checked against main at 9b5de01 rather than against PR titles. Two rows were bundling a feature with an unshipped destructive half — history shipped but delete did not, stats shipped but clear did not — so those are split into 1/1b and 2/2b. - Records that `shot_processing` and `radar_config` are typed but consumed by nothing, so a declared type is not evidence a feature exists. - Corrects two references to Expo SDK 54; the app moved to 57 in #10. - Adds the profile behaviours a client has to know because the wire does not carry them: refusals are indistinguishable from successes, add_profile also sets the active profile, and MAX_PROFILES/MAX_NAME_LENGTH are enforced server-side but never sent. Documentation only; no source or behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QvMGAzMMWRuxRht8aGciAv --- ROADMAP.md | 123 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 50 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a5102aa..b809154 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,15 +10,19 @@ rather than shared with the web `ui/`. The real source of truth is `src/openflight/server.py`'s `shot_to_dict()` payload and its SocketIO events in [open-flight/openflight](https://github.com/open-flight/openflight). +**Status legend:** ✅ shipped · 🟡 partly shipped · ⬜ not started. +Status reflects `main` at `9b5de01`. Each claim was checked against the code rather than +against the title of the PR that delivered it. + ## Locked decisions - **Scope:** Tier 1 (core golfer parity) + a foundation phase, plus the three sole-interface items (graceful shutdown, connection bootstrapping, device/status view). - **Control:** **Full control** — a sole interface must do everything the touchscreen - could. Destructive actions (**delete shot, clear session, shutdown**) are guarded by - **confirm dialogs**; the footgun is UI-level, not a network-trust problem. + could. Destructive actions (**delete shot, clear session, stop the server**) are guarded + by **confirm dialogs**; the footgun is UI-level, not a network-trust problem. - **Navigation:** **Bottom tab bar** (Live / Shots / Stats / Device) via **expo-router** - (the Expo SDK 54 default, file-based routing). + (file-based routing). - **Topology:** one app works in all three setups — phone-only (headless Pi), phone + kiosk touchscreen, and phone + passive `/display` monitor. Same server, same app. @@ -31,26 +35,20 @@ rework. --- -## Phase 0 — Foundation +## Phase 0 — Foundation ✅ **Goal:** restructure so features are cheap to add. Little new user-facing behavior. -Today the app puts socket logic, connection state, and shot state inline in `App.tsx` -with `useState`. That won't scale to multiple tabs sharing session state. Mirror the web -app's proven split (a singleton socket service + a store), kept self-contained here. +Delivered by #1, with connection recovery in #13 and the Expo SDK 54 → 57 upgrade in #10. -| Work item | Detail | Files | +| Work item | Status | Detail | |---|---|---| -| Navigation shell | Bottom tabs via expo-router (Live / Shots / Stats / Device). | new `app/` (expo-router) | -| Socket service | Extract inline socket logic into a singleton mirroring the web UI's `src/services/socketService.ts` — one place mapping every server event → store. DRY within mobile. | `services/socket.ts` | -| State store | A store shared across tabs (zustand works in RN; or reducer + context). Holds shots, connection, session-derived flags. | `stores/` | -| Connection persistence | Persist server URL via AsyncStorage; default to AP fixed IP `192.168.4.1:8080` with `192.168.1.100` as a fallback hint; auto-reconnect with backoff. | socket service + store | -| Wire-contract expansion | Grow `types.ts` to cover later-phase events (`session_state` extras, `shot_processing`, `session_cleared`, `club_changed`, `player_changed`, `trigger_status`, `power_status`). | `types.ts` | -| Test infra | There are zero tests in the app today. Stand up `jest-expo` + `@testing-library/react-native`. Prerequisite, not optional. | repo config | - -**Test story:** the socket service's event→state transitions are pure and highly testable; -cover connect/disconnect/reconnect and each event handler. **Size: M.** Risk: low, but -load-bearing. +| Navigation shell | ✅ #1 | Bottom tabs via expo-router (Live / Shots / Stats / Device). `app/` | +| Socket service | ✅ #1 | Singleton mirroring the web UI's `src/services/socketService.ts` — one place mapping every server event → store. `services/socket.ts` | +| State store | ✅ #1 | zustand stores shared across tabs. `stores/` | +| Connection persistence | ✅ #1, #13 | Server URL persisted via AsyncStorage; Socket.IO's own backoff handles reconnects. A failed or mistyped address is recoverable (#13). Auto-discovery is Phase 2 item 3. | +| Wire-contract expansion | ✅ #1, then per feature | `types.ts` covers `session_state` extras, `shot_processing`, `club_changed`, `profiles`, `trigger_status`, `power_status`, `radar_config`. Types land with the feature that consumes them, per `AGENTS.md`'s no-speculative-payloads rule — so a declared type does not by itself mean the feature ships. | +| Test infra | ✅ #1 | `jest-expo` + `@testing-library/react-native`, enforced by CI (#3). | --- @@ -58,18 +56,17 @@ load-bearing. **Goal:** the phone is a genuinely useful launch-monitor client. -| # | Feature | Emits / consumes | Notes | -|---|---|---|---| -| 1 | Shot history list + delete | `delete_shot` → `session_cleared`/`shot` | New Shots tab. Delete behind a confirm. | -| 2 | Session stats + clear | `clear_session` → `session_cleared` | New Stats tab. Port the web aggregates. Clear behind a confirm. | -| 3 | Club selection + on-connect prompt | `set_club` / `club_changed` | Mirror the web club-select screen on first connect; reflect server-pushed club changes. | -| 4 | Player selection | `set_player` / `player_changed` | | -| 5 | Unit toggle (imperial/metric) | client-side, persisted | The gauge/tiles hardcode mph/yds today — thread a unit through `CurrentShotView`. | -| 6 | Live polish | `shot_processing` | Show capturing/calculating states + a shot-arrival flash. Makes "waiting for a shot" feel alive. | - -**Test story:** unit conversion (pure, table-tested hard), stats aggregation (pure), and -reducer handling of delete/clear/club/player. Component test for club-select-on-connect. -**Size: L.** Risk: low; items are independent and land incrementally. +| # | Feature | Status | Emits / consumes | Notes | +|---|---|---|---|---| +| 1 | Shot history list | ✅ #16, #17 | — | Shots tab, kept on the device in SQLite. Swing-speed sessions fixed in #17. | +| 1b | Delete a shot | ⬜ | `delete_shot` → `session_state`, or `delete_shot_error` | Not started; `delete_shot` is emitted nowhere. Behind a confirm. Note the server answers a miss with `delete_shot_error`, not `session_cleared`. | +| 2 | Session stats | ✅ #20 | `session_state.stats` | Stats tab uses the kiosk's own aggregates rather than a local reimplementation. | +| 2b | Clear the session | ⬜ | `clear_session` → `session_cleared` | Not started. **Profile-scoped:** the payload is `{profile_id}`, defaulting to the active profile, and `session_cleared` returns `{profile_id, shots}` where `shots` is the whole remaining session. Behind a confirm. | +| 3 | Club selection | ✅ #19, #25 | `set_club` / `club_changed` | Canonical club list mirrored in #19; the picker in #25 reflects server-pushed changes from any client. | +| 3b | On-connect club prompt | ⬜ | — | The kiosk's club-select-on-first-connect screen has no mobile equivalent yet. | +| 4 | Profile selection | 🟡 #21 | `get_profiles`, `set_active_profile`, `add_profile`, `rename_profile`, `remove_profile` → `profiles` | Data layer shipped in #21; the picker UI is still to land. **This replaces what this roadmap previously called "player selection"** — see the contract note below. | +| 5 | Unit toggle (imperial/metric) | ✅ #18 | client-side, persisted | Ported from the kiosk. | +| 6 | Live polish | ⬜ | `shot_processing` | `ShotProcessingState` is typed but no handler consumes it. Capturing/calculating states and a shot-arrival flash are still to do. | --- @@ -77,21 +74,21 @@ reducer handling of delete/clear/club/player. Component test for club-select-on- **Goal:** a no-touchscreen build is fully operable and diagnosable from the phone. -| # | Feature | Mechanism | Notes | -|---|---|---|---| -| 1 | Graceful shutdown | `POST /api/shutdown` (already exists) | Confirm dialog → pending/success/error, mirroring web `ShutdownDialog`. Prevents yanking power on a live Pi (SD-card corruption risk). | -| 2 | Device/Status view | `trigger_status`, `radar_config` (read-only), `power_status` | New Device tab: connection health, radar/trigger status, battery if present. The troubleshooting lifeline when there's no screen. | -| 3 | Connection bootstrapping | mDNS discovery and/or AP default | "Just tap Connect" without reading an IP off a screen you removed. | +| # | Feature | Status | Mechanism | Notes | +|---|---|---|---|---| +| 1 | Stop the server gracefully | ✅ #26 | `POST /api/shutdown` | Confirm → pending/success/error. **Corrected:** this stops the OpenFlight **server process** (`_shutdown_process_after_delay` calls `os._exit(0)`). It does **not** power the Pi down, and neither `server.py` nor `start-kiosk.sh` has a poweroff path. Earlier revisions of this roadmap described it as preventing a power-yank on a live Pi, which invites the opposite reading; the UI is worded as stopping OpenFlight for that reason. | +| 2 | Device/Status view | ✅ #26 | `trigger_status`, `power_status`, `toggle_debug` / `debug_toggled` | Device tab: radar/trigger health, battery when a provider is present, and debug recording. `radar_config` is typed but not yet displayed. | +| 3 | Connection bootstrapping | ⬜ | mDNS discovery and/or AP default | Not started — no discovery code or dependency is present. "Just tap Connect" without reading an IP off a screen you removed. | **Test story:** shutdown state machine (confirm→pending→success/error) as a component -test; status view across present/absent hardware; discovery logic mockable. **Size: M.** -Risk: mDNS on RN can be fiddly (may need a dev-build native module, not pure Expo Go) — -ship AP-default first, treat mDNS as a stretch. Confirm the dev-build story against the -Expo SDK 54 / Expo Go constraint in `AGENTS.md` before adding native deps. +test; status view across present/absent hardware; discovery logic mockable. Risk: mDNS on +RN can be fiddly (may need a development build, not Expo Go) — ship AP-default first, +treat mDNS as a stretch. Confirm the development-build story against the Expo SDK 57 +constraint in `AGENTS.md` before adding native deps. --- -## Phase 3 — Deployment track (ops, parallel — not app code) +## Phase 3 — Deployment track (ops, parallel — not app code) ⬜ **Goal:** make the headless + AP topology real on the Pi. Independent of the app. @@ -112,24 +109,50 @@ Phase 0 (foundation) ──► Phase 1 (parity) ──► Phase 2 (sole-interfac Phase 3 (Pi AP + headless) ── independent, any time ──┘ ``` -Phase 0 gates everything. Phases 1 and 2 are each internally incremental (ship -item-by-item). Phase 3 is parallelizable. +Phase 0 gates everything and is complete. Phases 1 and 2 are each internally incremental +(ship item-by-item) and are being worked in parallel. Phase 3 is parallelizable. ## Cross-cutting principles - **DRY within this app**, but not across the web `ui/` ↔ mobile — the duplicated `Shot` type and event names are deliberate; keep them mirrored, not shared. -- **Confirm dialogs** on all three destructive actions (delete / clear / shutdown). +- **Confirm dialogs** on every destructive action (delete shot / clear session / stop the server). - **Tests land with each feature**, not after; Phase 0 exists partly to make that possible. - **Explicit over clever:** a plain socket-service + store, mirroring the web app's already-proven shape. ## Server-side contract reference -Client → server emits used by this roadmap: `get_session`, `set_club`, `set_player`, -`delete_shot`, `clear_session`, `simulate_shot`, `get_trigger_status`, `get_radar_config`; -plus `POST /api/shutdown`. - -Server → client events consumed: `session_state`, `shot`, `shot_processing`, -`session_cleared`, `club_changed`, `player_changed`, `trigger_status`, `radar_config`, -`power_status`. +Checked against `src/openflight/server.py` and `src/openflight/profiles.py`. + +Client → server emits used by this roadmap: `get_session`, `set_club`, `simulate_shot`, +`delete_shot`, `clear_session`, `get_profiles`, `set_active_profile`, `add_profile`, +`rename_profile`, `remove_profile`, `get_trigger_status`, `get_radar_config`, +`toggle_debug`; plus `POST /api/shutdown`. + +Server → client events consumed: `session_state`, `shot`, `shot_update`, +`shot_processing`, `session_cleared`, `club_changed`, `profiles`, `trigger_status`, +`radar_config`, `power_status`, `debug_toggled`, `delete_shot_error`. + +### Profiles replaced players + +**`set_player` and `player_changed` do not exist on the server.** Earlier revisions of this +roadmap listed them for Phase 1 item 4 and in the reference above, so anyone implementing +from the doc would have built against an API that was never there. The server models this +as **profiles**, and every shot carries `profile_id` / `profile_name`. + +Behaviour worth knowing before building against it: + +- Every mutation is answered with one full `profiles` snapshot — **including a mutation the + server refuses.** There is no error event and no ack, so a rejected request is + indistinguishable from an accepted one except by diffing the snapshot that follows. +- `add_profile` also makes the new profile active. A client must not follow it with + `set_active_profile`. +- `remove_profile` is refused if the profile is active, is the last one, or still has + session rows. +- The roster is never empty and `active_profile_id` is never empty: the server seeds a + profile when its file holds none. There is no empty-roster state to design for. +- `MAX_PROFILES` (12) and `MAX_NAME_LENGTH` (40) are enforced server-side but never sent + on the wire, so a client has to know them or the user hits a silent no-op. +- Names are neither unique nor a key — `id` is. A rename preserves the id, which is why + `shot.profile_name` is a capture-time snapshot and goes stale. From a41d220b930fe40f97b01cd11acc7965478f4f26 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Wed, 23 Sep 2026 16:35:49 -0400 Subject: [PATCH 2/3] docs(roadmap): stop claiming stats and units as shipped Three rows claimed more than main (9b5de01) delivers: - Session stats: the Stats tab is still a placeholder. The summary tiles live on the Shots tab and are computed on the device by utils/sessionStats.ts. They are not read from session_state, whose payload has no stats field. - Unit toggle: #18 ported only the conversion helpers. Nothing imports utils/units.ts, CurrentShotView still hardcodes mph/yds, and there is no toggle. - radar_config: types.ts declares no such type, so the rows describing it as typed but undisplayed were wrong. Also records that the default server URL is not yet the AP address, and lists the get_debug_status / debug_status pair the Device tab uses. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b809154..3f25b2f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,8 +46,8 @@ Delivered by #1, with connection recovery in #13 and the Expo SDK 54 → 57 upgr | Navigation shell | ✅ #1 | Bottom tabs via expo-router (Live / Shots / Stats / Device). `app/` | | Socket service | ✅ #1 | Singleton mirroring the web UI's `src/services/socketService.ts` — one place mapping every server event → store. `services/socket.ts` | | State store | ✅ #1 | zustand stores shared across tabs. `stores/` | -| Connection persistence | ✅ #1, #13 | Server URL persisted via AsyncStorage; Socket.IO's own backoff handles reconnects. A failed or mistyped address is recoverable (#13). Auto-discovery is Phase 2 item 3. | -| Wire-contract expansion | ✅ #1, then per feature | `types.ts` covers `session_state` extras, `shot_processing`, `club_changed`, `profiles`, `trigger_status`, `power_status`, `radar_config`. Types land with the feature that consumes them, per `AGENTS.md`'s no-speculative-payloads rule — so a declared type does not by itself mean the feature ships. | +| Connection persistence | ✅ #1, #13 | Server URL persisted via AsyncStorage; Socket.IO's own backoff handles reconnects. A failed or mistyped address is recoverable (#13). The default is still `192.168.1.100:8080`; switching it to the AP address `192.168.4.1:8080` waits on Phase 3. Auto-discovery is Phase 2 item 3. | +| Wire-contract expansion | ✅ #1, then per feature | `types.ts` covers `session_state` extras, `shot_processing`, `club_changed`, `profiles`, `trigger_status`, `power_status`. `radar_config` is not typed yet. Types land with the feature that consumes them, per `AGENTS.md`'s no-speculative-payloads rule — so a declared type does not by itself mean the feature ships. | | Test infra | ✅ #1 | `jest-expo` + `@testing-library/react-native`, enforced by CI (#3). | --- @@ -60,12 +60,12 @@ Delivered by #1, with connection recovery in #13 and the Expo SDK 54 → 57 upgr |---|---|---|---|---| | 1 | Shot history list | ✅ #16, #17 | — | Shots tab, kept on the device in SQLite. Swing-speed sessions fixed in #17. | | 1b | Delete a shot | ⬜ | `delete_shot` → `session_state`, or `delete_shot_error` | Not started; `delete_shot` is emitted nowhere. Behind a confirm. Note the server answers a miss with `delete_shot_error`, not `session_cleared`. | -| 2 | Session stats | ✅ #20 | `session_state.stats` | Stats tab uses the kiosk's own aggregates rather than a local reimplementation. | +| 2 | Session stats | 🟡 #16, #20 | — (computed on the device) | The Shots tab shows the summary tiles. `utils/sessionStats.ts` computes them from the local shot list using a hand-mirrored port of the kiosk's `computeStats` (#20). The Stats tab itself is still a placeholder. | | 2b | Clear the session | ⬜ | `clear_session` → `session_cleared` | Not started. **Profile-scoped:** the payload is `{profile_id}`, defaulting to the active profile, and `session_cleared` returns `{profile_id, shots}` where `shots` is the whole remaining session. Behind a confirm. | | 3 | Club selection | ✅ #19, #25 | `set_club` / `club_changed` | Canonical club list mirrored in #19; the picker in #25 reflects server-pushed changes from any client. | | 3b | On-connect club prompt | ⬜ | — | The kiosk's club-select-on-first-connect screen has no mobile equivalent yet. | | 4 | Profile selection | 🟡 #21 | `get_profiles`, `set_active_profile`, `add_profile`, `rename_profile`, `remove_profile` → `profiles` | Data layer shipped in #21; the picker UI is still to land. **This replaces what this roadmap previously called "player selection"** — see the contract note below. | -| 5 | Unit toggle (imperial/metric) | ✅ #18 | client-side, persisted | Ported from the kiosk. | +| 5 | Unit toggle (imperial/metric) | 🟡 #18 | client-side, persisted | #18 ported only the kiosk's conversion helpers (`utils/units.ts`), and nothing imports them yet. `CurrentShotView` still hardcodes mph/yds, and there is no toggle or persisted preference. | | 6 | Live polish | ⬜ | `shot_processing` | `ShotProcessingState` is typed but no handler consumes it. Capturing/calculating states and a shot-arrival flash are still to do. | --- @@ -77,7 +77,7 @@ Delivered by #1, with connection recovery in #13 and the Expo SDK 54 → 57 upgr | # | Feature | Status | Mechanism | Notes | |---|---|---|---|---| | 1 | Stop the server gracefully | ✅ #26 | `POST /api/shutdown` | Confirm → pending/success/error. **Corrected:** this stops the OpenFlight **server process** (`_shutdown_process_after_delay` calls `os._exit(0)`). It does **not** power the Pi down, and neither `server.py` nor `start-kiosk.sh` has a poweroff path. Earlier revisions of this roadmap described it as preventing a power-yank on a live Pi, which invites the opposite reading; the UI is worded as stopping OpenFlight for that reason. | -| 2 | Device/Status view | ✅ #26 | `trigger_status`, `power_status`, `toggle_debug` / `debug_toggled` | Device tab: radar/trigger health, battery when a provider is present, and debug recording. `radar_config` is typed but not yet displayed. | +| 2 | Device/Status view | ✅ #26 | `trigger_status`, `power_status`, `get_debug_status` / `debug_status`, `toggle_debug` / `debug_toggled` | Device tab: radar/trigger health, battery when a provider is present, and debug recording. The read-only `radar_config` view has not been started: nothing sends `get_radar_config`, and there is no type for it. | | 3 | Connection bootstrapping | ⬜ | mDNS discovery and/or AP default | Not started — no discovery code or dependency is present. "Just tap Connect" without reading an IP off a screen you removed. | **Test story:** shutdown state machine (confirm→pending→success/error) as a component From 7f1afda10b2d0edb2ad7fae5443eda2395341015 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Thu, 24 Sep 2026 09:12:19 -0400 Subject: [PATCH 3/3] docs(roadmap): split shipped from planned in the contract reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to a41d220, which flipped the stats and units rows but left the contract reference and the profile-limit note untouched. The reference listed every event the roadmap mentions in two flat lists, so a reader could not tell a registered handler from an event nothing listens to yet. `shot_processing`, `session_cleared` and `delete_shot_error` sat beside `session_state` and `shot` as though all five were wired. Emits and events are now split into shipped and planned, each planned name carrying the item number that will consume it. That also drops `get_radar_config` / `radar_config`, which appeared as both an emit and a consumed event despite radar-config editing being a stated non-goal and nothing in the app touching either name, and adds the `get_debug_status` / `debug_status` pair the Device tab has emitted and handled since #26. The profile-limit note treated MAX_PROFILES and MAX_NAME_LENGTH as one silent no-op. They differ. ProfileStore.add() returns None at 12 profiles and creates nothing, whereas clean_profile_name truncates an overlength name to 40 characters and the mutation then succeeds under the shortened name — the user gets a profile, just not the one they typed. A blank or whitespace-only name is a third outcome, rejected by both calls. Each is stated separately, with what a client should do about it. Two smaller corrections in passing, both from the same review: - Phase 1 item 5 still declared "persisted" in its emits/consumes column while its own note said no preference is persisted. The column now reads client-side, and the note names the remaining work. - Phase 0's covered-types list omitted `debug_status` and `debug_toggled`, which types.ts has declared since #26. Documentation only; no source or behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QvMGAzMMWRuxRht8aGciAv --- ROADMAP.md | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3f25b2f..190ae80 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,7 +47,7 @@ Delivered by #1, with connection recovery in #13 and the Expo SDK 54 → 57 upgr | Socket service | ✅ #1 | Singleton mirroring the web UI's `src/services/socketService.ts` — one place mapping every server event → store. `services/socket.ts` | | State store | ✅ #1 | zustand stores shared across tabs. `stores/` | | Connection persistence | ✅ #1, #13 | Server URL persisted via AsyncStorage; Socket.IO's own backoff handles reconnects. A failed or mistyped address is recoverable (#13). The default is still `192.168.1.100:8080`; switching it to the AP address `192.168.4.1:8080` waits on Phase 3. Auto-discovery is Phase 2 item 3. | -| Wire-contract expansion | ✅ #1, then per feature | `types.ts` covers `session_state` extras, `shot_processing`, `club_changed`, `profiles`, `trigger_status`, `power_status`. `radar_config` is not typed yet. Types land with the feature that consumes them, per `AGENTS.md`'s no-speculative-payloads rule — so a declared type does not by itself mean the feature ships. | +| Wire-contract expansion | ✅ #1, then per feature | `types.ts` covers `session_state` extras, `shot_processing`, `club_changed`, `profiles`, `trigger_status`, `power_status`, `debug_status`, `debug_toggled`. `radar_config` is not typed yet. Types land with the feature that consumes them, per `AGENTS.md`'s no-speculative-payloads rule — so a declared type does not by itself mean the feature ships. | | Test infra | ✅ #1 | `jest-expo` + `@testing-library/react-native`, enforced by CI (#3). | --- @@ -60,12 +60,12 @@ Delivered by #1, with connection recovery in #13 and the Expo SDK 54 → 57 upgr |---|---|---|---|---| | 1 | Shot history list | ✅ #16, #17 | — | Shots tab, kept on the device in SQLite. Swing-speed sessions fixed in #17. | | 1b | Delete a shot | ⬜ | `delete_shot` → `session_state`, or `delete_shot_error` | Not started; `delete_shot` is emitted nowhere. Behind a confirm. Note the server answers a miss with `delete_shot_error`, not `session_cleared`. | -| 2 | Session stats | 🟡 #16, #20 | — (computed on the device) | The Shots tab shows the summary tiles. `utils/sessionStats.ts` computes them from the local shot list using a hand-mirrored port of the kiosk's `computeStats` (#20). The Stats tab itself is still a placeholder. | +| 2 | Session stats | 🟡 #16, #20 | — (computed on the device) | The Shots tab shows the summary tiles. `utils/sessionStats.ts` computes them from the local shot list using a hand-mirrored port of the kiosk's `computeStats` (#20) — `session_state` carries no `stats` field to read instead. The Stats tab itself is still a placeholder; a real one is the remaining work. | | 2b | Clear the session | ⬜ | `clear_session` → `session_cleared` | Not started. **Profile-scoped:** the payload is `{profile_id}`, defaulting to the active profile, and `session_cleared` returns `{profile_id, shots}` where `shots` is the whole remaining session. Behind a confirm. | | 3 | Club selection | ✅ #19, #25 | `set_club` / `club_changed` | Canonical club list mirrored in #19; the picker in #25 reflects server-pushed changes from any client. | | 3b | On-connect club prompt | ⬜ | — | The kiosk's club-select-on-first-connect screen has no mobile equivalent yet. | | 4 | Profile selection | 🟡 #21 | `get_profiles`, `set_active_profile`, `add_profile`, `rename_profile`, `remove_profile` → `profiles` | Data layer shipped in #21; the picker UI is still to land. **This replaces what this roadmap previously called "player selection"** — see the contract note below. | -| 5 | Unit toggle (imperial/metric) | 🟡 #18 | client-side, persisted | #18 ported only the kiosk's conversion helpers (`utils/units.ts`), and nothing imports them yet. `CurrentShotView` still hardcodes mph/yds, and there is no toggle or persisted preference. | +| 5 | Unit toggle (imperial/metric) | 🟡 #18 | client-side | #18 ported only the kiosk's conversion helpers (`utils/units.ts`), and nothing imports them yet. `CurrentShotView` and the Shots list still hardcode mph/yds, and there is no toggle or persisted preference. Remaining: a control, persistence, and routing the displays through the helpers. | | 6 | Live polish | ⬜ | `shot_processing` | `ShotProcessingState` is typed but no handler consumes it. Capturing/calculating states and a shot-arrival flash are still to do. | --- @@ -125,14 +125,26 @@ Phase 0 gates everything and is complete. Phases 1 and 2 are each internally inc Checked against `src/openflight/server.py` and `src/openflight/profiles.py`. -Client → server emits used by this roadmap: `get_session`, `set_club`, `simulate_shot`, -`delete_shot`, `clear_session`, `get_profiles`, `set_active_profile`, `add_profile`, -`rename_profile`, `remove_profile`, `get_trigger_status`, `get_radar_config`, -`toggle_debug`; plus `POST /api/shutdown`. +Shipped and planned are listed separately. A name under "planned" exists on the server but +has no caller or handler in this app yet, so it states an intention, not the current wiring. -Server → client events consumed: `session_state`, `shot`, `shot_update`, -`shot_processing`, `session_cleared`, `club_changed`, `profiles`, `trigger_status`, -`radar_config`, `power_status`, `debug_toggled`, `delete_shot_error`. +**Client → server, shipped** — all in `services/socket.ts`: `get_session`, +`get_trigger_status`, `get_debug_status` and `get_profiles` on connect; `simulate_shot`, +`set_club`, `toggle_debug`, `set_active_profile`, `add_profile`, `rename_profile` and +`remove_profile` on user action. Plus `POST /api/shutdown`, which is HTTP rather than +socket traffic. + +**Client → server, planned:** `delete_shot` (item 1b), `clear_session` (item 2b). + +**Server → client, handlers registered:** `session_state`, `shot`, `shot_update`, +`club_changed`, `profiles`, `trigger_status`, `power_status`, `debug_status` and +`debug_toggled` — plus the transport's own `connect` / `disconnect` / `connect_error`. + +**Server → client, no handler yet:** `shot_processing` (item 6), `session_cleared` +(item 2b), `delete_shot_error` (item 1b). + +`get_radar_config` / `radar_config` are in neither list. Radar-config editing is an explicit +non-goal above, and nothing in the app emits, types or consumes either name. ### Profiles replaced players @@ -152,7 +164,16 @@ Behaviour worth knowing before building against it: session rows. - The roster is never empty and `active_profile_id` is never empty: the server seeds a profile when its file holds none. There is no empty-roster state to design for. -- `MAX_PROFILES` (12) and `MAX_NAME_LENGTH` (40) are enforced server-side but never sent - on the wire, so a client has to know them or the user hits a silent no-op. +- `MAX_PROFILES` (12) and `MAX_NAME_LENGTH` (40) are both enforced server-side and neither + is sent on the wire, so a client has to know them. They do **not** fail the same way: + - **At 12 profiles the add is rejected** — `ProfileStore.add()` returns `None` and no + profile is created. With only a snapshot in reply, that reads as a silent no-op, so a + client should disable the control at 12 rather than let the request disappear. + - **An overlength name is accepted and silently truncated** to its first 40 characters by + `clean_profile_name`, for `add_profile` and `rename_profile` alike. The mutation + succeeds; the profile simply comes back renamed. Cap the input at 40 so the server does + not quietly rewrite what the user typed. + - A name that is empty or whitespace-only is rejected outright by both calls — a third + outcome, and again answered with an unchanged snapshot. - Names are neither unique nor a key — `id` is. A rename preserves the id, which is why `shot.profile_name` is a capture-time snapshot and goes stale.