From 7506a6f2538b852888d8a8350a7f3345eea11588 Mon Sep 17 00:00:00 2001 From: Taha Bouhsine Date: Sun, 24 May 2026 06:04:50 -0700 Subject: [PATCH 1/6] feat(bridge): /session/replay/{start,stop,status} for simulated car MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a session-replay feature so the PWA can drive a "simulated car" from a previously-recorded SQLite session. The replay thread reads the target session's wide rows + tall signals, groups them by nearest wide timestamp, and re-publishes canonical wide_snapshot frames to telemetry_bus at the original cadence (or scaled via the `speed` param). Contract: POST /session/replay/start body {source_session_id, speed?, loop?} -> 200 {replay_id, source_session_id, total_frames, est_duration_s, speed, loop} -> 404 if source has no telemetry rows -> 409 if a replay is already running -> 503 if no DB backend reachable POST /session/replay/stop -> 200 {stopped, frames_emitted} GET /session/replay/status -> 200 {running, source_session_id, speed, loop, frame_idx, total_frames, elapsed_s, est_remaining_s} Behaviour: - Sets state.active_session_id to source_session_id so /health and the existing SSE stream pick the replay up automatically (no PWA changes required). - Single-publisher invariant: if a CAN reader is running at start, stop it cleanly so frames don't interleave. - Wide_snapshot uses PWA-facing keys (speed/rpm/brake_pressure/throttle/ steering/g_lat/g_long/distance/lat/lon) — NOT the v3.0 raw names (speed_ms/brake_bar/etc). - Sleep is capped at 1.0 s per chunk so /stop responds within ~1 s. - On loop=true, restarts from frame 0 at end of source. Smoke-tested on the phone against the 12,936-row Sonoma recording: status -> idle, start (speed=10, loop=true) -> running, 254 frames emitted in 3 s, SSE delivered canonical-shape frames, stop returned {stopped:true, frames_emitted:260} in ~1 s. --- src/pitwall/__init__.py | 6 +- src/pitwall/features/session/bp_replay.py | 355 ++++++++++++++++++++++ 2 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 src/pitwall/features/session/bp_replay.py diff --git a/src/pitwall/__init__.py b/src/pitwall/__init__.py index df89d82..20e9dfa 100644 --- a/src/pitwall/__init__.py +++ b/src/pitwall/__init__.py @@ -33,14 +33,16 @@ def register_blueprints(app): from pitwall.features.bp_core import bp as bp_core from pitwall.features.session.bp_session import bp as bp_session from pitwall.features.session.bp_analysis import bp as bp_analysis + from pitwall.features.session.bp_replay import bp as bp_replay from pitwall.features.coaching.bp_coaching import bp as bp_coaching from pitwall.features.telemetry.bp_signals import bp as bp_signals from pitwall.features.track.bp_track import bp as bp_track from pitwall.features.realtime.bp_realtime import bp as bp_realtime from pitwall.features.bp_diagnostics import bp as bp_diagnostics - for blueprint in (bp_core, bp_session, bp_analysis, bp_coaching, - bp_signals, bp_track, bp_realtime, bp_diagnostics): + for blueprint in (bp_core, bp_session, bp_analysis, bp_replay, + bp_coaching, bp_signals, bp_track, bp_realtime, + bp_diagnostics): app.register_blueprint(blueprint) def create_app() -> Flask: diff --git a/src/pitwall/features/session/bp_replay.py b/src/pitwall/features/session/bp_replay.py new file mode 100644 index 0000000..72416d8 --- /dev/null +++ b/src/pitwall/features/session/bp_replay.py @@ -0,0 +1,355 @@ +"""bridge.bp_replay — Blueprint: session replay (simulated car from recording). + +Drives the PWA's live HUD from a previously-recorded session in the bridge +DB, instead of from a real CAN bus. Publishes frames to `telemetry_bus` +with the exact same canonical shape `can_reader._flush_wide` uses, so the +PWA can't tell the difference between live and replay. + +Endpoints: + POST /session/replay/start — body { source_session_id, speed, loop } + POST /session/replay/stop — { stopped, frames_emitted } + GET /session/replay/status — running/idle, frame_idx, elapsed_s, ... + +One replay thread per bridge process. Starting a replay while a CAN reader +is running stops the CAN reader first (avoids two publishers on the same +session_id). +""" + +import logging +import threading +import time + +from flask import Blueprint, request, jsonify + +from pitwall.state import state +from pitwall.db import db_conn, DuckDbUnavailable, WIDE_SIGNAL_NAMES # noqa: F401 + + +log = logging.getLogger(__name__) + +bp = Blueprint("replay", __name__) + + +# ── Module-level replay state ──────────────────────────────────────────────── + +_replay_lock = threading.Lock() +_replay_thread: threading.Thread | None = None +_replay_stop_event = threading.Event() +_replay_status: dict = {"running": False} + + +# Mapping from the wide-table column names to the SSE canonical names that +# `can_reader._flush_wide` publishes. The PWA reads these — don't rename. +_WIDE_COL_TO_SSE = { + "timestamp": "timestamp", + "distance_m": "distance", + "speed_ms": "speed", + "g_lat": "g_lat", + "g_long": "g_long", + "combo_g": "combo_g", + "brake_bar": "brake_pressure", + "throttle_pct": "throttle", + "steering_deg": "steering", + "rpm": "rpm", + "lat": "lat", + "lon": "lon", +} + + +def _load_replay_data(sid: str): + """Return (wide_rows, extras_by_frame_idx) for the given session. + + wide_rows: list of dicts in canonical SSE shape, one per telemetry row. + extras_by_frame_idx: list[dict] parallel to wide_rows; each dict holds + the latest-known tall-signal value (by signal name) at that frame's + timestamp, for signal names not already in the wide snapshot. + """ + with db_conn() as conn: + wide = conn.execute( + """SELECT timestamp, distance_m, speed_ms, g_lat, g_long, + combo_g, brake_bar, throttle_pct, steering_deg, + rpm, lat, lon + FROM telemetry + WHERE session_id = ? + ORDER BY timestamp""", + [sid], + ).fetchall() + + # Tall signals join their human-readable name from signal_registry. + tall = conn.execute( + """SELECT ts.t, sr.name, ts.value + FROM telemetry_signals ts + JOIN signal_registry sr USING(signal_id) + WHERE ts.session_id = ? + ORDER BY ts.t""", + [sid], + ).fetchall() + + wide_cols = ("timestamp", "distance_m", "speed_ms", "g_lat", "g_long", + "combo_g", "brake_bar", "throttle_pct", "steering_deg", + "rpm", "lat", "lon") + + wide_rows: list[dict] = [] + for row in wide: + snap = {} + for col, val in zip(wide_cols, row): + sse_key = _WIDE_COL_TO_SSE[col] + snap[sse_key] = float(val) if val is not None else None + wide_rows.append(snap) + + # Group tall samples by nearest wide-row timestamp. Walk both lists in + # one O(N+M) pass — both are timestamp-sorted. + extras_by_frame: list[dict] = [dict() for _ in wide_rows] + if not wide_rows or not tall: + return wide_rows, extras_by_frame + + # latest[name] = most recent value seen so far across all earlier tall samples + latest: dict[str, float] = {} + j = 0 # cursor into tall + n_tall = len(tall) + wide_ts = [r["timestamp"] for r in wide_rows] + n_wide = len(wide_rows) + + # Skip any used SSE canonical names so extras don't shadow them. + canonical_keys = set(_WIDE_COL_TO_SSE.values()) + + for i in range(n_wide): + t_i = wide_ts[i] + # advance j while tall_t <= t_i + while j < n_tall and tall[j][0] <= t_i: + _, name, value = tall[j] + if name not in canonical_keys and value is not None: + try: + latest[name] = float(value) + except (TypeError, ValueError): + pass + j += 1 + # Snapshot of latest tall values at this frame + if latest: + extras_by_frame[i] = dict(latest) + + return wide_rows, extras_by_frame + + +def _replay_loop(source_sid: str, speed: float, loop: bool, + wide_rows: list[dict], extras_by_frame: list[dict]): + """Background thread: emit frames to telemetry_bus at recorded cadence.""" + try: + from pitwall.features.realtime.bp_realtime import telemetry_bus + except Exception as e: # noqa: BLE001 + log.exception("replay: failed to import telemetry_bus: %s", e) + with _replay_lock: + _replay_status.clear() + _replay_status.update({"running": False, "error": str(e)}) + return + + n_frames = len(wide_rows) + started_at = time.monotonic() + frames_emitted = 0 + last_log_idx = -1 + + try: + while not _replay_stop_event.is_set(): + for i in range(n_frames): + if _replay_stop_event.is_set(): + break + snap = wide_rows[i] + extras = extras_by_frame[i] or {} + frame = {**snap, **{k: v for k, v in extras.items() + if k not in snap}} + telemetry_bus.publish(source_sid, frame) + frames_emitted += 1 + + # Update status (cheap, under lock) + with _replay_lock: + _replay_status["frame_idx"] = i + _replay_status["total_frames"] = n_frames + _replay_status["elapsed_s"] = time.monotonic() - started_at + _replay_status["frames_emitted"] = frames_emitted + # est_remaining_s = (last_ts - cur_ts) / speed + last_ts = wide_rows[-1].get("timestamp") or 0.0 + cur_ts = snap.get("timestamp") or 0.0 + rem = max(0.0, (last_ts - cur_ts)) / max(speed, 1e-6) + _replay_status["est_remaining_s"] = rem + + # Keep state.active_session_id accurate (for /health) + state.active_session_id = source_sid + + if i // 500 != last_log_idx: + last_log_idx = i // 500 + log.info("replay: frame %d/%d (sid=%s)", + i, n_frames, source_sid) + + # Sleep to the next frame's timestamp + if i + 1 < n_frames: + t_now = snap.get("timestamp") + t_next = wide_rows[i + 1].get("timestamp") + if t_now is not None and t_next is not None: + dt = (t_next - t_now) / max(speed, 1e-6) + if dt < 0: + dt = 0.0 + # Cap individual sleep so a stop signal isn't blocked + # for too long. Use Event.wait so we exit early on stop. + remaining = dt + while remaining > 0 and not _replay_stop_event.is_set(): + chunk = min(remaining, 1.0) + if _replay_stop_event.wait(timeout=chunk): + break + remaining -= chunk + + if not loop or _replay_stop_event.is_set(): + break + # On loop, restart timing baseline so elapsed_s reflects current pass + started_at = time.monotonic() + log.info("replay: looping back to frame 0 (sid=%s)", source_sid) + except Exception as e: # noqa: BLE001 + log.exception("replay: thread crashed: %s", e) + with _replay_lock: + _replay_status.clear() + _replay_status.update({ + "running": False, + "error": str(e), + "frames_emitted": frames_emitted, + }) + return + + with _replay_lock: + _replay_status["running"] = False + _replay_status["frames_emitted"] = frames_emitted + log.info("replay: thread exiting (frames_emitted=%d, loop=%s)", + frames_emitted, loop) + + +# ── HTTP routes ────────────────────────────────────────────────────────────── + +@bp.route("/session/replay/start", methods=["POST"]) +def replay_start(): + """Start a session replay. Body JSON: source_session_id, speed, loop.""" + global _replay_thread + + body = request.get_json(silent=True) or {} + sid = (body.get("source_session_id") or "").strip() + if not sid: + return jsonify({"error": "source_session_id required"}), 400 + try: + speed = float(body.get("speed", 1.0)) + except (TypeError, ValueError): + speed = 1.0 + if speed <= 0: + speed = 1.0 + loop = bool(body.get("loop", False)) + + with _replay_lock: + if _replay_thread is not None and _replay_thread.is_alive(): + return jsonify({ + "error": "a replay is already running; stop it first", + "source_session_id": _replay_status.get("source_session_id"), + }), 409 + + # Load recorded frames (outside the lock — DB read can take a moment) + try: + wide_rows, extras_by_frame = _load_replay_data(sid) + except DuckDbUnavailable: + return jsonify({"error": "no DB backend available"}), 503 + except Exception as e: # noqa: BLE001 + log.exception("replay: load failed: %s", e) + return jsonify({"error": f"failed to load replay data: {e}"}), 500 + + if not wide_rows: + return jsonify({ + "error": "source session has no telemetry rows", + "source_session_id": sid, + }), 404 + + # est_duration_s = (last_ts - first_ts) / speed + first_ts = wide_rows[0].get("timestamp") or 0.0 + last_ts = wide_rows[-1].get("timestamp") or 0.0 + est_duration_s = max(0.0, (last_ts - first_ts)) / max(speed, 1e-6) + + # Stop any running CAN reader to avoid two publishers on the same sid + if state.can_reader is not None: + try: + log.info("replay: stopping existing CAN reader before replay") + state.can_reader.stop(timeout=2.0) + except Exception as e: # noqa: BLE001 + log.warning("replay: CAN reader stop raised: %s", e) + state.can_reader = None + + state.active_session_id = sid + + with _replay_lock: + _replay_stop_event.clear() + _replay_status.clear() + _replay_status.update({ + "running": True, + "source_session_id": sid, + "speed": speed, + "loop": loop, + "frame_idx": 0, + "total_frames": len(wide_rows), + "elapsed_s": 0.0, + "est_remaining_s": est_duration_s, + "frames_emitted": 0, + }) + _replay_thread = threading.Thread( + target=_replay_loop, + args=(sid, speed, loop, wide_rows, extras_by_frame), + name="pitwall-replay", + daemon=True, + ) + _replay_thread.start() + + return jsonify({ + "replay_id": sid, + "source_session_id": sid, + "total_frames": len(wide_rows), + "est_duration_s": est_duration_s, + "speed": speed, + "loop": loop, + }), 200 + + +@bp.route("/session/replay/stop", methods=["POST"]) +def replay_stop(): + """Stop the running replay (no-op if not running).""" + global _replay_thread + + with _replay_lock: + running = (_replay_thread is not None + and _replay_thread.is_alive()) + if not running: + return jsonify({ + "stopped": False, + "frames_emitted": int(_replay_status.get("frames_emitted", 0)), + }), 200 + thread = _replay_thread + + _replay_stop_event.set() + thread.join(timeout=2.0) + + with _replay_lock: + _replay_thread = None + _replay_status["running"] = False + frames_emitted = int(_replay_status.get("frames_emitted", 0)) + + return jsonify({"stopped": True, "frames_emitted": frames_emitted}), 200 + + +@bp.route("/session/replay/status", methods=["GET"]) +def replay_status(): + """Snapshot of the replay state. {"running": false} when idle.""" + with _replay_lock: + running = (_replay_thread is not None + and _replay_thread.is_alive()) + if not running: + # If a thread crashed it may have left running=False + error set + if _replay_status.get("error"): + return jsonify({ + "running": False, + "error": _replay_status.get("error"), + "frames_emitted": int(_replay_status.get("frames_emitted", 0)), + }), 200 + return jsonify({"running": False}), 200 + # Return a shallow copy so callers don't see further mutation mid-serialise + snap = dict(_replay_status) + return jsonify(snap), 200 From 21b1b823141fdcd18efac9f7d720f67ff69bedae Mon Sep 17 00:00:00 2001 From: Taha Bouhsine Date: Sun, 24 May 2026 11:28:30 -0700 Subject: [PATCH 2/6] fix(pwa): memory leaks + stable v-for keys (PR-B) Memory-leak hardening: - HardwareDetail: null out liveTimer/histTimer refs after clearInterval in onUnmounted (telemetry.close() + clears were already wired). - PedalProfile: null out refetchTimer ref after clearTimeout in onUnmounted. v-for key stability (replace array index with stable identity): - OnboardingFlow.vue: step dots -> `step-${i}` (i is 1..N from totalSteps; stable per slot). - AvatarSelect.vue: avatar buttons -> avatar.id. - PedalProfile.vue: friction G-G samples -> composite of index + (gLat,gLong); utilisation histogram bins -> `bin-${i*10}`. - GlobalLeaderboard.vue: rows -> `rank-${entry.rank}-${entry.initials}` (future-proof; store is currently empty). - SqlConsole.vue: result rows -> `row-${i}-${JSON.stringify(row)}`; examples list -> ex.title. - DriverEvolution.vue: heatmap cells -> `${c.id}-lap-${i}` composite. - AskCoachMode.vue: conversation turns -> t.recorded_at when present, else composite of (i, role, text-prefix). --- .../driver-evolution/DriverEvolution.vue | 49 +++++++++---------- .../pages/hardware-detail/HardwareDetail.vue | 10 +++- .../pages/leaderboard/GlobalLeaderboard.vue | 23 ++++++--- .../src/pages/onboarding/OnboardingFlow.vue | 2 +- .../pages/onboarding/steps/AvatarSelect.vue | 4 +- .../src/pages/pedal-profile/PedalProfile.vue | 9 ++-- .../src/pages/quest-log/ui/AskCoachMode.vue | 2 +- src/pwa/src/pages/sql-console/SqlConsole.vue | 4 +- 8 files changed, 59 insertions(+), 44 deletions(-) diff --git a/src/pwa/src/pages/driver-evolution/DriverEvolution.vue b/src/pwa/src/pages/driver-evolution/DriverEvolution.vue index d8f43ca..0dfb321 100644 --- a/src/pwa/src/pages/driver-evolution/DriverEvolution.vue +++ b/src/pwa/src/pages/driver-evolution/DriverEvolution.vue @@ -141,19 +141,14 @@ onMounted(async () => { }) // Per-sector PB heatmap: gradient from worst→best PB across the evolution -// timeline. Falls back to flat charcoal when no sector PBs were returned. +// timeline. Returns [] when no sector PBs were returned — the template +// renders a "no historical data yet" empty state. No synthetic +// pre-launch heatmap. const cornerPBs = computed(() => { const keys = new Set() for (const s of sessions.value) Object.keys(s.sectorPbs).forEach((k) => keys.add(k)) const sectorKeys = Array.from(keys).sort() - if (!sectorKeys.length) { - // Pre-launch synthetic data so the screen still has visible structure. - return [ - { id: 'S1', grades: [1, 1, 2, 3, 4] }, - { id: 'S2', grades: [0, 1, 1, 1, 2] }, - { id: 'S3', grades: [2, 2, 3, 3, 3] }, - ] - } + if (!sectorKeys.length) return [] as { id: string; grades: number[] }[] return sectorKeys.map((k) => { const vals = sessions.value.map((s) => s.sectorPbs[k] ?? Number.POSITIVE_INFINITY) const finite = vals.filter((v) => Number.isFinite(v)) @@ -237,7 +232,7 @@ const getHeatmapColor = (grade: number) => {
Best sector gain: {{ heroData.biggestGain.corner }} - −{{ heroData.biggestGain.deltaKmh.toFixed(2) }}s + −{{ heroData.biggestGain.deltaSec.toFixed(2) }}s since session #1
@@ -283,20 +278,25 @@ const getHeatmapColor = (grade: number) => {
PER-CORNER
HEATMAP
-
-
- #1#{{ sessions.length }} -
-
- {{ c.id }} -
-
+
+ NO HISTORICAL SECTOR DATA YET. +
+
@@ -308,10 +308,9 @@ const getHeatmapColor = (grade: number) => { diff --git a/src/pwa/src/pages/hardware-detail/HardwareDetail.vue b/src/pwa/src/pages/hardware-detail/HardwareDetail.vue index 6626648..2ffcf76 100644 --- a/src/pwa/src/pages/hardware-detail/HardwareDetail.vue +++ b/src/pwa/src/pages/hardware-detail/HardwareDetail.vue @@ -213,8 +213,14 @@ const updateLiveValues = () => { } onUnmounted(() => { - if (liveTimer) window.clearInterval(liveTimer) - if (histTimer) window.clearInterval(histTimer) + if (liveTimer) { + window.clearInterval(liveTimer) + liveTimer = null + } + if (histTimer) { + window.clearInterval(histTimer) + histTimer = null + } telemetry.close() }) diff --git a/src/pwa/src/pages/leaderboard/GlobalLeaderboard.vue b/src/pwa/src/pages/leaderboard/GlobalLeaderboard.vue index d3e90e3..04ea9e4 100644 --- a/src/pwa/src/pages/leaderboard/GlobalLeaderboard.vue +++ b/src/pwa/src/pages/leaderboard/GlobalLeaderboard.vue @@ -56,17 +56,24 @@ const getRankColor = (rank: number) => { -
- COMING SOON +
+ GLOBAL LEADERBOARD +

+ Endpoint not yet available on bridge +

- Global leaderboards aren't wired to the bridge yet — multi-driver - benchmarking + privacy controls land post-Sonoma. For now your - session-by-session times live in + Multi-driver benchmarking + privacy controls land post-Sonoma. + For now your session-by-session times live in LAP TIMES HALL and DRIVER EVOLUTION.

+
+ LEADERBOARD UNAVAILABLE +

{{ store.error }}

+
+
@@ -81,9 +88,9 @@ const getRankColor = (rank: number) => { - { SET UP YOUR DRIVER · - +
diff --git a/src/pwa/src/pages/onboarding/steps/AvatarSelect.vue b/src/pwa/src/pages/onboarding/steps/AvatarSelect.vue index f284d83..e30da27 100644 --- a/src/pwa/src/pages/onboarding/steps/AvatarSelect.vue +++ b/src/pwa/src/pages/onboarding/steps/AvatarSelect.vue @@ -63,8 +63,8 @@ const selectAvatar = (index: number) => {
{ }) onUnmounted(() => { - if (refetchTimer) window.clearTimeout(refetchTimer) + if (refetchTimer) { + window.clearTimeout(refetchTimer) + refetchTimer = null + } }) const gradeColor: Record = { @@ -389,7 +392,7 @@ const gradeColor: Record = { = {
diff --git a/src/pwa/src/pages/quest-log/ui/AskCoachMode.vue b/src/pwa/src/pages/quest-log/ui/AskCoachMode.vue index 4e4cc54..0903f71 100644 --- a/src/pwa/src/pages/quest-log/ui/AskCoachMode.vue +++ b/src/pwa/src/pages/quest-log/ui/AskCoachMode.vue @@ -269,7 +269,7 @@ function exampleClick(text: string) { No active conversation. Ask the coach a question above.
-
+
{{ t.role === 'user' ? 'YOU' : 'COACH' }} diff --git a/src/pwa/src/pages/sql-console/SqlConsole.vue b/src/pwa/src/pages/sql-console/SqlConsole.vue index 0dd2e05..85318a4 100644 --- a/src/pwa/src/pages/sql-console/SqlConsole.vue +++ b/src/pwa/src/pages/sql-console/SqlConsole.vue @@ -212,7 +212,7 @@ onMounted(() => { - + {{ row[key] }} @@ -231,7 +231,7 @@ onMounted(() => {
EXAMPLE QUERIES
-
{{ exampleIndex === i ? '▶ ' : '' }}{{ ex.title }} From 7f42f1a69b3ac1ec9cc71e9013c7f8a91938db80 Mon Sep 17 00:00:00 2001 From: Taha Bouhsine Date: Sun, 24 May 2026 11:29:19 -0700 Subject: [PATCH 3/6] feat(pwa): pit-stall SSE during replay + install prompt + fullscreen persistence (PR-C) --- src/pwa/src/pages/pit-stall/PitStall.vue | 89 ++++++-- .../fullscreen-toggle/FullscreenToggle.vue | 87 ++++++- .../widgets/install-prompt/InstallPrompt.vue | 213 ++++++++++++++++++ 3 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 src/pwa/src/widgets/install-prompt/InstallPrompt.vue diff --git a/src/pwa/src/pages/pit-stall/PitStall.vue b/src/pwa/src/pages/pit-stall/PitStall.vue index 5a2118f..d51f1b9 100644 --- a/src/pwa/src/pages/pit-stall/PitStall.vue +++ b/src/pwa/src/pages/pit-stall/PitStall.vue @@ -158,6 +158,34 @@ const carDetails = computed(() => { return [track, `session: ${sid}`] }) +// ── Accessible state phrasing ─────────────────────────────────────────────── +// The status dots/check-glyphs in each ConnRow are color-only — screen +// readers get nothing useful from "✓" or a green pixel. These computed +// strings are rendered in visually-hidden `` nodes +// alongside each row so assistive tech announces the same signal the +// sighted user sees in the dot. +const stateWord = (s: 'checking' | 'ok' | 'error' | 'pending'): string => { + switch (s) { + case 'ok': return 'online' + case 'error': return 'offline' + case 'pending': return 'pending' + case 'checking': return 'checking' + } +} + +const bridgeA11yLabel = computed(() => + `Bridge ${stateWord(bridgeState.value)} — ${bridgeDetails.value.join(', ')}`, +) +const usbCanA11yLabel = computed(() => + `USB-CAN ${stateWord(usbCanState.value)} — ${usbCanDetails.value.join(', ')}`, +) +const dbcA11yLabel = computed(() => + `DBC ${stateWord(dbcState.value)} — ${dbcDetails.value.join(', ')}`, +) +const carA11yLabel = computed(() => + `Car ${stateWord(carState.value)} — ${carDetails.value.join(', ')}`, +) + // ── Live values: SSE telemetry frame + diagnostics.frames_per_second ──────── const liveState = computed(() => { @@ -287,6 +315,19 @@ watch(carState, (s) => { } }) +// Replay-aware SSE gate: session replay bypasses the CAN reader so +// `can.connected` stays false and `carState` never reaches 'ok'. Open +// the telemetry stream whenever the bridge advertises an active session +// regardless of CAN state. `telemetry.open()` calls `close()` first so +// this is idempotent with the carState-driven open above. +watch( + () => bridgeStore.health?.active_session_id, + (sid) => { + if (sid) telemetry.open(sid) + }, + { immediate: true }, +) + const reboot = () => { // Reset boot-log narrative so the user sees a fresh sequence. bootLogs.value = [] @@ -313,19 +354,21 @@ onUnmounted(() => { telemetry.close() }) +const openLiveWall = () => { + audio.playSfx('cursor_select') + router.push('/pit-stall/live') +} + useKeyboard((e: KeyboardEvent) => { if (e.key === 'Escape' || e.key === 'Backspace' || e.key === 'b') { audio.playSfx('cancel') router.push('/garage') } else if (e.key === 'r' || e.key === 'R') { reboot() - } else if (e.key === 'Enter') { - if (carState.value === 'ok') { - audio.playSfx('cursor_select') - router.push('/pit-stall/live') - } else { - audio.playSfx('error_quiet') - } + } else if (e.key === 'Enter' || e.key === 'l' || e.key === 'L') { + // Live wall is always reachable — replay sessions don't satisfy + // carState === 'ok' but still produce telemetry worth viewing. + openLiveWall() } }) @@ -334,7 +377,7 @@ useKeyboard((e: KeyboardEvent) => { {
-
- - - - +
+
+ + {{ bridgeA11yLabel }} +
+
+ + {{ usbCanA11yLabel }} +
+
+ + {{ dbcA11yLabel }} +
+
+ + {{ carA11yLabel }} +
@@ -393,7 +448,13 @@ useKeyboard((e: KeyboardEvent) => {
- + + OPEN LIVE PIT WALL
diff --git a/src/pwa/src/widgets/fullscreen-toggle/FullscreenToggle.vue b/src/pwa/src/widgets/fullscreen-toggle/FullscreenToggle.vue index 697ef19..a8886bd 100644 --- a/src/pwa/src/widgets/fullscreen-toggle/FullscreenToggle.vue +++ b/src/pwa/src/widgets/fullscreen-toggle/FullscreenToggle.vue @@ -23,25 +23,76 @@ const supported = ref( const isFs = ref(false) +// ───────────────────────────────────────────────────────────────────────────── +// Preference persistence +// +// The Web Fullscreen API drops fullscreen on every SPA route change (browser +// security: only same-document navigations preserve it). Without persistence +// the user has to tap the toggle on every page. We remember the user's +// last explicit choice in localStorage and try to re-enter fullscreen on +// mount. Re-entry requires a user gesture per spec, so the auto-request will +// often reject silently — that's expected, not an error. +// ───────────────────────────────────────────────────────────────────────────── +const STORAGE_KEY = 'fullscreenPreferred' + +const readPref = (): boolean => { + try { + return localStorage.getItem(STORAGE_KEY) === 'true' + } catch { + return false + } +} + +const writePref = (v: boolean) => { + try { + localStorage.setItem(STORAGE_KEY, v ? 'true' : 'false') + } catch { + /* storage may be disabled (private mode, etc.) — silently ignore */ + } +} + +// Installed-PWA detection: when the manifest's display mode is `fullscreen` +// (or `standalone`) the WebAPK is already in fullscreen and the JS +// Fullscreen API is irrelevant. Skip auto-request in that case. +const isStandaloneOrManifestFullscreen = (): boolean => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false + return ( + window.matchMedia('(display-mode: fullscreen)').matches || + window.matchMedia('(display-mode: standalone)').matches + ) +} + const refresh = () => { isFs.value = Boolean(doc.fullscreenElement || doc.webkitFullscreenElement) } +const enterFs = async (): Promise => { + if (typeof docEl.requestFullscreen === 'function') { + await docEl.requestFullscreen() + } else if (typeof docEl.webkitRequestFullscreen === 'function') { + await docEl.webkitRequestFullscreen() + } +} + +const exitFs = async (): Promise => { + if (typeof doc.exitFullscreen === 'function') { + await doc.exitFullscreen() + } else if (typeof doc.webkitExitFullscreen === 'function') { + await doc.webkitExitFullscreen() + } +} + const toggle = async () => { + const goingFullscreen = !isFs.value try { if (isFs.value) { - if (typeof doc.exitFullscreen === 'function') { - await doc.exitFullscreen() - } else if (typeof doc.webkitExitFullscreen === 'function') { - await doc.webkitExitFullscreen() - } + await exitFs() } else { - if (typeof docEl.requestFullscreen === 'function') { - await docEl.requestFullscreen() - } else if (typeof docEl.webkitRequestFullscreen === 'function') { - await docEl.webkitRequestFullscreen() - } + await enterFs() } + // Only persist on success — a rejected request shouldn't lock us into + // an auto-retry loop on every page. + writePref(goingFullscreen) } catch { /* user denied or transient — silently ignore */ } @@ -55,6 +106,22 @@ onMounted(() => { refresh() document.addEventListener('fullscreenchange', refresh) document.addEventListener('webkitfullscreenchange', refresh) + + // Re-enter fullscreen across SPA route changes when the user has + // previously opted in. The spec requires a user gesture; outside of + // one (e.g. a router-driven mount) the promise will reject and we + // silently fall back to the toggle button. + if ( + supported.value && + readPref() && + !doc.fullscreenElement && + !doc.webkitFullscreenElement && + !isStandaloneOrManifestFullscreen() + ) { + enterFs().catch(() => { + /* expected: requestFullscreen without a fresh user gesture rejects */ + }).finally(refresh) + } }) onUnmounted(() => { diff --git a/src/pwa/src/widgets/install-prompt/InstallPrompt.vue b/src/pwa/src/widgets/install-prompt/InstallPrompt.vue new file mode 100644 index 0000000..39e59f2 --- /dev/null +++ b/src/pwa/src/widgets/install-prompt/InstallPrompt.vue @@ -0,0 +1,213 @@ + + + Surfaces Chrome's `beforeinstallprompt` event as a small sticky bottom- + banner so users don't have to dig in the kebab menu to install the PWA. + No-ops on iOS Safari (no BIP event), in already-installed standalone + contexts, and for 7 days after the user dismisses. +--> + + + + + From 013b148769bc76e1426322699bf8c568dad49ca8 Mon Sep 17 00:00:00 2001 From: Taha Bouhsine Date: Sun, 24 May 2026 11:30:48 -0700 Subject: [PATCH 4/6] fix(pwa): unified key contract + nav discoverability (PR-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the keyboard contract across the PWA: ESC is now "go back" on every page (router.back) and P is the pause overlay (in-session pages only, opted in via route.meta.allowPause). Fixes a bag of nav-discoverability papercuts shipped on landscape phones. 1. App.vue — ESC routes back globally (or dismisses pause if visible); P only toggles pauseStore on routes with meta.allowPause === true. Removed the old ESC-as-pause-anywhere global handler. 2. router/index.ts + OnTrackHud.vue — /hud opts into meta.allowPause; HUD's local handler now uses P for its pause overlay and treats B as a resume alias. 3. GarageHub.vue + CoachFloat.vue — raised CoachFloat z-index from 20 to 100 so the welcome dialogue can never be covered by a focused spatial tile. Hint bar now lists B/C/H so previously-hidden shortcuts (CALIBRATE, HARDWARE) are discoverable. 4. PreBrief.vue — already had WALK · W in its actions array; verified. 5. OnTrackHud.vue — added a top-right ESC · BACK / P · PAUSE corner hint so the immersive HUD has a visible escape hatch; wrapped the 10px status-pip in a 44x44 hit target (WCAG 2.5.5). 6. TelemetryReplay.vue — decoupled from can.connected; SSE subscribes whenever bridgeStore.health.active_session_id is non-null, mirroring PitStall's watch shape, so replay-only sessions are not blocked by missing CAN. 7. HintBar.vue — dev-only sanity-check stub for missing key hints, tree-shaken from prod; TODO documents the useKeyboard registry it would consume. 8. SaveSelect.vue — dropped the redundant window.addEventListener keydown listener; HOLD-B-DELETE now goes through useKeyboard with a single narrow keyup listener for the release edge. --- src/pwa/src/app/App.vue | 26 ++++++++- src/pwa/src/app/router/index.ts | 2 +- .../pages/analysis-hub/TelemetryReplay.vue | 39 ++++++++++++- src/pwa/src/pages/garage-hub/GarageHub.vue | 13 ++++- src/pwa/src/pages/on-track-hud/OnTrackHud.vue | 58 ++++++++++++++++++- src/pwa/src/pages/save-select/SaveSelect.vue | 38 ++++++------ src/pwa/src/shared/ui/CoachFloat.vue | 5 +- src/pwa/src/widgets/hint-bar/HintBar.vue | 27 +++++++++ 8 files changed, 180 insertions(+), 28 deletions(-) diff --git a/src/pwa/src/app/App.vue b/src/pwa/src/app/App.vue index 3a1930f..b9fe5b3 100644 --- a/src/pwa/src/app/App.vue +++ b/src/pwa/src/app/App.vue @@ -1,6 +1,6 @@