Planned in advance
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run

This commit is contained in:
David 2026-07-04 22:26:10 +02:00
parent a79537479b
commit 28305ac477

View file

@ -1,7 +1,7 @@
# TrainUs — Implementation Plan (v3) # TrainUs — Implementation Plan (v3)
> **Last updated:** 2026-06-09 > **Last updated:** 2026-07-04
> **Status:** Step 15 complete — all steps done; GNU AGPL v3 license + notice added; license shown via stacked modal in About dialog > **Status:** Steps 2426 planned (app-update modal, headset media controls, voice announcements) — ready for implementation, one step at a time with user validation between steps
## Overview ## Overview
@ -787,6 +787,132 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
--- ---
### Step 24 — App Update Modal (no more silent freeze)
**Status:** 🔲 Planned (2026-07-04)
**Problem:** the service worker is registered with `registerType: 'autoUpdate'` (`vite.config.js`). When a new version is deployed, the new SW downloads the full precache (including `sqlite3.wasm`, ~10 MB), takes control and reloads — the user experiences an unexplained freeze, even when no DB migration is involved. (The DB migration screen from Step 16 only covers schema changes, not app-code updates.)
**Goal:** the user is informed that an update is available, chooses when to apply it, and sees an explicit "updating…" state instead of a freeze.
#### Decisions
| Decision | Choice | Rationale |
| --------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Update strategy | Switch VitePWA to `registerType: 'prompt'` | The new SW installs (precaches) in the background but waits; activation becomes a user decision |
| UI | Modal via existing `ModalDialog.vue`: "Update now" / "Later" | Reuse existing modal component (KISS) |
| "Update now" | Show non-dismissible spinner state, call `updateServiceWorker(true)` | The page reloads when the new SW takes control; the spinner explains the wait |
| "Later" | Dismiss for the current session; modal reappears on next app load | Same session-dismiss pattern as `InstallPrompt.vue`; no persisted flag needed |
| During execution mode | Do not show the modal on `#/sessions/:id/execute`; defer until leaving | Never interrupt a workout with an update prompt |
| E2E testing | Dev-only test hook to force `needRefresh` | Simulating a real SW update in Playwright requires two builds; the real flow is validated manually |
#### Files to create / modify
**New:**
- `src/composables/useAppUpdate.js` — singleton wrapping `useRegisterSW` from `virtual:pwa-register/vue`: exposes `needRefresh`, `updating`, `applyUpdate()` (sets `updating`, calls `updateServiceWorker(true)`), `dismiss()` (session-only). Guards a dev-only hook `window.__appUpdateTest = { trigger() }` behind `import.meta.env.DEV` so Playwright can force the prompt.
- `src/components/AppUpdateDialog.vue` — modal with two states: (1) choice — title, short body ("A new version of TrainUs is ready"), buttons **Update now** / **Later**; (2) updating — spinner + "Updating…" text, no buttons, not dismissible (no Escape/backdrop close). Hidden while the current route is the execution view; shows automatically once the user navigates away.
- `tests/test_step24_app_update.py`
**Modified:**
- `vite.config.js``registerType: 'autoUpdate'``'prompt'`
- `src/App.vue` — mount `<AppUpdateDialog>` once in the app-shell block (alongside `<BackupReminder>` / `<InstallPrompt>` / `<FirstLoadWarning>`)
- `src/i18n/locales/en.json`, `fr.json`, `da.json` — new `appUpdate: { title, body, updateNow, later, updating }` block
- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md`
**Verification:** Playwright tests (via the dev test hook) — modal appears when an update is pending; "Later" closes it and it does not reappear within the session; "Update now" switches to the spinner state and calls the (mocked) update function; spinner state ignores Escape; modal is suppressed on the execution route and appears after navigating away. Real SW update flow validated manually (deploy old build → deploy new build → prompt → update → reload). Firefox + Chromium.
---
### Step 25 — Headset / Media-Key Control of Session Execution
**Status:** 🔲 Planned (2026-07-04)
**Goal:** during session execution, headphone buttons (and OS media keys / lock-screen controls) drive the workout: play/pause, next, previous, stop — via the **Media Session API** (`navigator.mediaSession`).
**Key constraint:** browsers only route hardware media keys to a page that is actively playing audio through a media element — Web Audio beeps do not qualify. The standard workaround is a short **silent looping `<audio>` element** started when execution begins (the user's click on ▶ satisfies the autoplay policy). This is what makes the whole feature work; it must be documented in `technical.en.md`.
#### Decisions
| Action | Mapping | Rationale |
| --------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `play`/`pause` | `togglePause()` when a timer is active; update `mediaSession.playbackState`; no-op otherwise | Matches the on-screen pause button semantics |
| `nexttrack` | `markDone()` on a normal step; `Continue` on final-round / AMRAP time-up screens | "Next" always means "advance", whatever screen is showing |
| `previoustrack` | New `goBack()` in `useSessionExecution.js`: decrement index, reload step state; no-op at index 0 | Lets the user redo the previous exercise |
| On-screen "Previous" button | Also add a visible ← button in `SessionExecuteView.vue` next to the Done button, calling the same `goBack()`; disabled on the first step | Feature usable without a headset; directly testable in the UI (user decision 2026-07-04) |
| `stop` | `exit()` directly (partial log saved, status `aborted`), skipping any confirmation | A headset button cannot answer a confirm dialog |
| `goBack()` log behavior | Already-saved log items are kept; completing the step again appends a new log item | KISS — no log rewriting; acceptable duplication, recorded in the decisions log |
| Timed steps on `goBack()` | The step timer restarts from the top of its slot/phase | Consistent with `loadStepState()`; simplest correct behavior |
| Silent audio | Tiny silent WAV as data URI, `loop = true`; started on execution start, stopped on done/exit/unmount | No asset file needed; released cleanly |
| Pause behavior | Keep the silent audio playing; only set `mediaSession.playbackState = 'paused'` | Pausing the element would deactivate the media session on some browsers |
| Metadata | `MediaMetadata { title: exerciseTitle, artist: comboTitle ?? sessionTitle, album: sessionTitle }`, updated on every step change | Lock screen / headset display shows the current exercise |
| Unsupported browser | Feature-detect `'mediaSession' in navigator`; silently no-op | Progressive enhancement |
#### Files to create / modify
**New:**
- `src/composables/useMediaSession.js``start({ onPlayPause, onNext, onPrevious, onStop })` (create + play silent audio, register action handlers), `updateMetadata(step, sessionTitle)`, `setPlaybackState(state)`, `stop()` (unregister handlers, stop + release audio). All guarded by feature detection.
- `tests/test_step25_media_session.py`
**Modified:**
- `src/composables/useSessionExecution.js` — add `goBack()`; expose it
- `src/views/SessionExecuteView.vue` — wire `useMediaSession`: start on mount (after `init()`), update metadata on `currentStep` change (watch), map actions per the table above, stop on unmount / `sessionDone`; add the on-screen "Previous" button (`data-testid="execute-previous-btn"`, `bi-skip-start-fill`, aria-label, disabled on the first step) next to the Done button
- `src/i18n/locales/{en,fr,da}.json``execution.previous` (button title/aria-label); other strings use existing titles
- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md` (mention headset control + browser support caveats)
**Verification:** Playwright tests — mock `navigator.mediaSession` via `page.add_init_script` (capture `setActionHandler` registrations and `metadata` assignments into `window.__mediaSessionMock`), then invoke the captured handlers from the test: `nexttrack` advances a step and saves the log item; `nexttrack` on the final-round screen continues to the next item; `previoustrack` returns to the previous step and is a no-op on the first step; the on-screen Previous button does the same and is disabled on the first step; `play`/`pause` toggles the timer on a timed combo and playbackState updates; `stop` exits and saves a partial log with status `aborted`; metadata title equals the current exercise title and updates on advance; handlers are unregistered after exit; app works with `mediaSession` absent (red path). Firefox + Chromium.
---
### Step 26 — Voice Announcements (Web Speech API)
**Status:** 🔲 Planned (2026-07-04)
**Goal:** in execution mode, a synthesized voice (SpeechSynthesis — `window.speechSynthesis`, no audio assets, supported by Firefox and Chromium) announces the exercise title at each step change. In TABATA and EMOM the *next* exercise is pre-announced. The voice can be disabled in Settings → Sound.
#### Announcement rules
| Moment | Spoken text | Notes |
| ------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------ |
| Step becomes current | the exercise title | Includes the very first step; skipped if this exact step was just pre-announced |
| EMOM: 10 s left in a slot | "Next: {next exercise title}" (translated prefix) | Hooked into the existing `onTick` (like `_emomSounds`); only if a next step exists |
| TABATA: rest phase opens | "Next: {next exercise title}" | Fires in `_startRestPhase`, mirroring the on-screen "Next:" label |
| AMRAP / NONE / plain exercises | step-change announcement only | No pre-announcement |
#### Decisions
| Decision | Choice | Rationale |
| ------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| API | `speechSynthesis` + `SpeechSynthesisUtterance` | Native, offline-capable, no dependency — fits the PWA |
| Utterance language | From current i18next language: `en``en-US`, `fr``fr-FR`, `da``da-DK` | Browser picks a matching installed voice; titles are spoken as-is |
| Volume | `utterance.volume = audioVolume` (existing `audio_volume` setting, already loaded in `useSessionExecution`) | One volume knob for beeps and voice |
| Overlap | `speechSynthesis.cancel()` before each `speak()` | A new announcement always preempts a stale one |
| Double announcement | Track the last pre-announced queue index; skip the step-change announcement for that index | Avoids "Squat" being spoken twice within seconds |
| Setting | New `settings` key `voice_enabled`, default enabled (`"true"`); checkbox in the existing **Sound** section of `SettingsView.vue` | User asked for an opt-out, not an opt-in |
| Unsupported browser | Feature-detect `'speechSynthesis' in window`; silently no-op (checkbox still shown) | Progressive enhancement |
| Session end/exit | `speechSynthesis.cancel()` on exit/done/unmount | No announcement after leaving the execution view |
#### Files to create / modify
**New:**
- `src/composables/useSpeech.js``speak(text, { volume })` (cancel + utter with mapped `lang`), `cancel()`, `isSupported`; reads `voice_enabled` once per execution via `settingsRepository`
- `tests/test_step26_voice_announcements.py`
**Modified:**
- `src/composables/useSessionExecution.js` — load `voice_enabled` in `init()`; announce in `loadStepState()`; pre-announce in the EMOM tick (at `remaining === 10`) and in `_startRestPhase()`; cancel on `exit()` / `_finish()`
- `src/views/SettingsView.vue` — "Voice announcements" checkbox in the Sound section, persisted like `audio_volume`
- `src/i18n/locales/{en,fr,da}.json``settings.voiceAnnouncements`, `execution.voice.next` ("Next: {{title}}" / "Suivant : {{title}}" / da)
- `docs/decisions-log.md`, `website/content/developers/technical.en.md`, `website/content/docs/user-guide/index.{en,fr}.md`
**Verification:** Playwright tests — mock `speechSynthesis` / `SpeechSynthesisUtterance` via `page.add_init_script` (record `{ text, lang, volume }` into `window.__spokenTexts`); use `page.clock` (as in Step 15) for timer-driven cases. Green paths: first step title spoken on execution start; title spoken on advance; EMOM speaks "Next: X" at 10 s remaining; TABATA speaks "Next: X" when rest opens; no duplicate announcement right after a pre-announcement; utterance `lang` follows the app language; utterance volume follows `audio_volume`. Red paths: checkbox off → nothing spoken (and persists across reload); `speechSynthesis` absent → execution works without errors. Firefox + Chromium.
---
## Data Model ## Data Model
```mermaid ```mermaid