Add udpate modal (step 24) and control by audio cask (step 25)
This commit is contained in:
parent
28305ac477
commit
49aaa0cd31
|
|
@ -2,6 +2,26 @@
|
||||||
|
|
||||||
Record of architectural and technical decisions made during development.
|
Record of architectural and technical decisions made during development.
|
||||||
|
|
||||||
|
## 2026-07-05 — Headset / media-key control of session execution (Step 25)
|
||||||
|
|
||||||
|
**Decision:** Added `useMediaSession.js`, wiring the Media Session API (`navigator.mediaSession`) into `SessionExecuteView` so headphone buttons, OS media keys, and lock-screen controls can drive a workout: play/pause toggles the active timer, next/previous step through the queue, and stop exits immediately. Hardware media keys are only routed to a page actively playing audio through a media element — the Web Audio beeps from Step 12 don't qualify — so `useMediaSession.js` also starts a tiny silent looping `<audio>` element (a 592-byte base64 WAV data URI, no asset file) when execution begins; the user's ▶ tap to start the session satisfies the autoplay policy. `MediaMetadata` (title = exercise, artist/album = combo/session title) is refreshed on every step change so the lock screen shows the current exercise. The whole composable feature-detects `'mediaSession' in navigator` and no-ops silently where unsupported.
|
||||||
|
|
||||||
|
Action mapping mirrors the on-screen buttons rather than inventing new semantics: `nexttrack` calls the same handler as **Done** on a normal step and **Continue** on the final-round/AMRAP-time-up screens; `previoustrack` calls a new `goBack()` in `useSessionExecution.js` (decrements the queue index and reloads step state, restarting any timer from the top of its slot/phase — a no-op at the first step); `stop` calls `exit()` directly with no confirmation, since a headset button can't answer a confirm dialog (partial log saved, `status = 'aborted'`). A matching on-screen **Previous** button (`bi-skip-start-fill`, disabled on the first step) was added next to Done so the feature — and `goBack()` — is directly usable and testable without a headset.
|
||||||
|
|
||||||
|
`goBack()` deliberately does not rewrite history: already-saved log items for a step are left in place, and redoing that step appends a new log item (accepted duplication, simplest correct behavior — KISS over log-rewriting complexity for what should be a rare correction).
|
||||||
|
|
||||||
|
**Rationale:** A phone typically stays locked in a pocket or armband during a workout; headset/lock-screen control is the only realistic way to advance steps without unlocking and touching the screen. Reusing the existing Done/Continue/Exit handlers instead of parallel media-specific logic keeps behavior identical between touch and hardware-key control paths. Testing mocks `navigator.mediaSession` (capturing `setActionHandler` registrations and `metadata`/`playbackState` assignments) since simulating real hardware media keys in Playwright isn't possible.
|
||||||
|
|
||||||
|
## 2026-07-05 — App update modal: service worker switched from `autoUpdate` to `prompt` (Step 24)
|
||||||
|
|
||||||
|
**Decision:** Switched `vite-plugin-pwa`'s `registerType` from `autoUpdate` to `prompt` and added an update dialog. With `autoUpdate`, a new deployment made the new service worker download the full precache (including the ~8 MB `sqlite3.wasm`), take control, and reload — experienced by the user as an unexplained freeze (the Step 16 migration screen only covers database schema changes, not app-code updates). With `prompt`, the new SW still precaches in the background but waits; a new `AppUpdateDialog.vue` (reusing `ModalDialog.vue`) offers **Update now** / **Later**. "Update now" switches the modal to a non-dismissible spinner state and calls `updateServiceWorker(true)` — the page reloads when the new SW takes control, so the spinner simply explains the wait. "Later" dismisses for the current session only (same pattern as `InstallPrompt.vue`, no persisted flag), so the prompt returns on the next app load. The modal is suppressed while the current route is the execution view (`session-execute`) and shows automatically once the user navigates away — an update prompt must never interrupt a workout.
|
||||||
|
|
||||||
|
The non-dismissible updating state needed no change to `ModalDialog`: passing an empty title removes the header close button, and the component's `close` event (Escape/backdrop) is simply ignored while updating.
|
||||||
|
|
||||||
|
The SW state is owned by a new singleton composable `useAppUpdate.js` wrapping `useRegisterSW` from `virtual:pwa-register/vue` (importing the virtual module also replaces the plugin's auto-injected registration script). Because simulating a real SW update in Playwright would require two production builds, the composable exposes a dev-only hook (`window.__appUpdateTest`, guarded by `import.meta.env.DEV`) letting tests force `needRefresh` and mock the update call; the real flow (deploy old build → deploy new build → prompt → update → reload) is validated manually.
|
||||||
|
|
||||||
|
**Rationale:** An update should be a user decision with visible feedback, not a silent multi-megabyte download followed by an unexplained reload — especially in a fitness app where the freeze could land mid-workout. `prompt` is the vite-plugin-pwa-supported way to get exactly that behavior while keeping the background precache (the update itself stays fast once accepted).
|
||||||
|
|
||||||
## 2026-07-04 — First-launch warning modal (single tab / no private browsing)
|
## 2026-07-04 — First-launch warning modal (single tab / no private browsing)
|
||||||
|
|
||||||
**Decision:** Added `FirstLoadWarning.vue`, a one-time modal shown on first app launch warning that TrainUs (1) can only be open in one browser tab at a time and (2) does not work in private/incognito browsing. Modeled on `BackupReminder.vue`'s conditional-modal pattern: checks a new `first_load_warning_seen` settings key on mount, shows the modal if unset, and persists the flag permanently on dismiss (no session-only snooze — this is a one-time notice, not a recurring reminder). Both warnings share a single modal and a single "Got it" dismiss button per the original request, rather than two separate dialogs.
|
**Decision:** Added `FirstLoadWarning.vue`, a one-time modal shown on first app launch warning that TrainUs (1) can only be open in one browser tab at a time and (2) does not work in private/incognito browsing. Modeled on `BackupReminder.vue`'s conditional-modal pattern: checks a new `first_load_warning_seen` settings key on mount, shows the modal if unset, and persists the flag permanently on dismiss (no session-only snooze — this is a one-time notice, not a recurring reminder). Both warnings share a single modal and a single "Got it" dismiss button per the original request, rather than two separate dialogs.
|
||||||
|
|
|
||||||
18
docs/plan.md
18
docs/plan.md
|
|
@ -1,7 +1,7 @@
|
||||||
# TrainUs — Implementation Plan (v3)
|
# TrainUs — Implementation Plan (v3)
|
||||||
|
|
||||||
> **Last updated:** 2026-07-04
|
> **Last updated:** 2026-07-05
|
||||||
> **Status:** Steps 24–26 planned (app-update modal, headset media controls, voice announcements) — ready for implementation, one step at a time with user validation between steps
|
> **Status:** Step 24 implemented (app-update modal) — awaiting manual validation; Step 25 implemented (headset media controls) — awaiting manual validation; Step 26 planned (voice announcements)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|
@ -789,7 +789,7 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
|
||||||
|
|
||||||
### Step 24 — App Update Modal (no more silent freeze)
|
### Step 24 — App Update Modal (no more silent freeze)
|
||||||
|
|
||||||
**Status:** 🔲 Planned (2026-07-04)
|
**Status:** ✅ Implemented (2026-07-05) — awaiting manual validation by the user. Implemented as planned below; 7 Playwright tests passing on Firefox + Chromium (real SW update flow still to be validated manually on a deployed build).
|
||||||
|
|
||||||
**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.)
|
**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.)
|
||||||
|
|
||||||
|
|
@ -798,7 +798,7 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
|
||||||
#### Decisions
|
#### Decisions
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
| 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 |
|
| 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) |
|
| 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 |
|
| "Update now" | Show non-dismissible spinner state, call `updateServiceWorker(true)` | The page reloads when the new SW takes control; the spinner explains the wait |
|
||||||
|
|
@ -827,7 +827,7 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
|
||||||
|
|
||||||
### Step 25 — Headset / Media-Key Control of Session Execution
|
### Step 25 — Headset / Media-Key Control of Session Execution
|
||||||
|
|
||||||
**Status:** 🔲 Planned (2026-07-04)
|
**Status:** ✅ Implemented (2026-07-05) — awaiting manual validation by the user. Implemented as planned below; 9 Playwright tests passing on Firefox + Chromium (real headset/lock-screen control still to be validated manually on a device).
|
||||||
|
|
||||||
**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`).
|
**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`).
|
||||||
|
|
||||||
|
|
@ -836,7 +836,7 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
|
||||||
#### Decisions
|
#### Decisions
|
||||||
|
|
||||||
| Action | Mapping | Rationale |
|
| Action | Mapping | Rationale |
|
||||||
| --------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `play`/`pause` | `togglePause()` when a timer is active; update `mediaSession.playbackState`; no-op otherwise | Matches the on-screen pause button semantics |
|
| `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 |
|
| `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 |
|
| `previoustrack` | New `goBack()` in `useSessionExecution.js`: decrement index, reload step state; no-op at index 0 | Lets the user redo the previous exercise |
|
||||||
|
|
@ -871,12 +871,12 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
|
||||||
|
|
||||||
**Status:** 🔲 Planned (2026-07-04)
|
**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.
|
**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
|
#### Announcement rules
|
||||||
|
|
||||||
| Moment | Spoken text | Notes |
|
| Moment | Spoken text | Notes |
|
||||||
| ------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------ |
|
| ------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||||
| Step becomes current | the exercise title | Includes the very first step; skipped if this exact step was just pre-announced |
|
| 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 |
|
| 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 |
|
| TABATA: rest phase opens | "Next: {next exercise title}" | Fires in `_startRestPhase`, mirroring the on-screen "Next:" label |
|
||||||
|
|
@ -885,7 +885,7 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
|
||||||
#### Decisions
|
#### Decisions
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
| Decision | Choice | Rationale |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
| API | `speechSynthesis` + `SpeechSynthesisUtterance` | Native, offline-capable, no dependency — fits the PWA |
|
| 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 |
|
| 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 |
|
| Volume | `utterance.volume = audioVolume` (existing `audio_volume` setting, already loaded in `useSessionExecution`) | One volume knob for beeps and voice |
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export default [
|
||||||
Set: 'readonly',
|
Set: 'readonly',
|
||||||
// Web APIs
|
// Web APIs
|
||||||
AudioContext: 'readonly',
|
AudioContext: 'readonly',
|
||||||
|
Audio: 'readonly',
|
||||||
|
MediaMetadata: 'readonly',
|
||||||
TextEncoder: 'readonly',
|
TextEncoder: 'readonly',
|
||||||
TextDecoder: 'readonly',
|
TextDecoder: 'readonly',
|
||||||
btoa: 'readonly',
|
btoa: 'readonly',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { useTranslation } from 'i18next-vue'
|
import { useTranslation } from 'i18next-vue'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import AppUpdateDialog from './components/AppUpdateDialog.vue'
|
||||||
import BackupReminder from './components/BackupReminder.vue'
|
import BackupReminder from './components/BackupReminder.vue'
|
||||||
import FirstLoadWarning from './components/FirstLoadWarning.vue'
|
import FirstLoadWarning from './components/FirstLoadWarning.vue'
|
||||||
import InstallPrompt from './components/InstallPrompt.vue'
|
import InstallPrompt from './components/InstallPrompt.vue'
|
||||||
|
|
@ -166,6 +167,7 @@ const pageTitle = computed(() => {
|
||||||
<BackupReminder :key="route.fullPath" />
|
<BackupReminder :key="route.fullPath" />
|
||||||
<FirstLoadWarning />
|
<FirstLoadWarning />
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
<AppUpdateDialog />
|
||||||
<nav class="navbar" :aria-label="$t('nav.mainNav')" @keydown="onNavKeydown">
|
<nav class="navbar" :aria-label="$t('nav.mainNav')" @keydown="onNavKeydown">
|
||||||
<div class="navbar-brand">{{ $t('app.name') }}</div>
|
<div class="navbar-brand">{{ $t('app.name') }}</div>
|
||||||
|
|
||||||
|
|
|
||||||
85
src/components/AppUpdateDialog.vue
Normal file
85
src/components/AppUpdateDialog.vue
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useAppUpdate } from '../composables/useAppUpdate.js'
|
||||||
|
import ModalDialog from './ModalDialog.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { needRefresh, updating, dismissed, applyUpdate, dismiss } = useAppUpdate()
|
||||||
|
|
||||||
|
// Never interrupt a workout: hidden on the execution route, shows
|
||||||
|
// automatically once the user navigates away.
|
||||||
|
const show = computed(
|
||||||
|
() => needRefresh.value && !dismissed.value && route.name !== 'session-execute',
|
||||||
|
)
|
||||||
|
|
||||||
|
// While updating the modal is not dismissible: no title means ModalDialog
|
||||||
|
// renders no header close button, and onClose swallows Escape/backdrop.
|
||||||
|
function onClose() {
|
||||||
|
if (!updating.value) dismiss()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ModalDialog v-if="show" :title="updating ? '' : $t('appUpdate.title')" @close="onClose">
|
||||||
|
<div data-testid="app-update-modal">
|
||||||
|
<template v-if="!updating">
|
||||||
|
<p class="modal-message">{{ $t('appUpdate.body') }}</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn" data-testid="app-update-later" @click="dismiss">
|
||||||
|
{{ $t('appUpdate.later') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" data-testid="app-update-now" @click="applyUpdate">
|
||||||
|
{{ $t('appUpdate.updateNow') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="updating-state" data-testid="app-update-updating">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<p class="modal-message">{{ $t('appUpdate.updating') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-message {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updating-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updating-state .modal-message {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--border-color);
|
||||||
|
border-top-color: var(--btn-primary-bg);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
src/composables/useAppUpdate.js
Normal file
38
src/composables/useAppUpdate.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||||
|
|
||||||
|
// Singleton: one service-worker registration and one shared update state for
|
||||||
|
// the whole app. With `registerType: 'prompt'` (vite.config.js) the new SW
|
||||||
|
// precaches in the background and waits; activation is a user decision.
|
||||||
|
const { needRefresh, updateServiceWorker } = useRegisterSW()
|
||||||
|
|
||||||
|
const updating = ref(false)
|
||||||
|
const dismissed = ref(false)
|
||||||
|
|
||||||
|
async function applyUpdate() {
|
||||||
|
updating.value = true
|
||||||
|
// The page reloads when the new service worker takes control; `updating`
|
||||||
|
// is intentionally never reset — the spinner covers the wait until reload.
|
||||||
|
const update =
|
||||||
|
(import.meta.env.DEV && window.__appUpdateTest?.mockUpdateFn) || updateServiceWorker
|
||||||
|
await update(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
dismissed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// Test hook: simulating a real SW update in Playwright would require two
|
||||||
|
// production builds, so tests force the prompt and mock the update instead.
|
||||||
|
window.__appUpdateTest = {
|
||||||
|
trigger: () => {
|
||||||
|
needRefresh.value = true
|
||||||
|
},
|
||||||
|
mockUpdateFn: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppUpdate() {
|
||||||
|
return { needRefresh, updating, dismissed, applyUpdate, dismiss }
|
||||||
|
}
|
||||||
61
src/composables/useMediaSession.js
Normal file
61
src/composables/useMediaSession.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Hardware media keys (headset buttons, OS media keys, lock-screen controls)
|
||||||
|
// are only routed to a page actively playing audio through a media element —
|
||||||
|
// Web Audio beeps do not qualify. A silent looping <audio> element (started
|
||||||
|
// from the user's ▶ click, satisfying the autoplay policy) is what makes the
|
||||||
|
// Media Session API actionable here.
|
||||||
|
const SILENT_LOOP_WAV =
|
||||||
|
'data:audio/wav;base64,UklGRrQBAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YZABAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA'
|
||||||
|
|
||||||
|
export function useMediaSession() {
|
||||||
|
const isSupported = typeof navigator !== 'undefined' && 'mediaSession' in navigator
|
||||||
|
let audioEl = null
|
||||||
|
|
||||||
|
function start({ onPlayPause, onNext, onPrevious, onStop } = {}) {
|
||||||
|
if (!isSupported) return
|
||||||
|
|
||||||
|
if (!audioEl) {
|
||||||
|
audioEl = new Audio(SILENT_LOOP_WAV)
|
||||||
|
audioEl.loop = true
|
||||||
|
}
|
||||||
|
audioEl.play().catch(() => {
|
||||||
|
// Autoplay may be rejected outside a user gesture — best effort.
|
||||||
|
})
|
||||||
|
|
||||||
|
const ms = navigator.mediaSession
|
||||||
|
ms.setActionHandler('play', () => onPlayPause?.())
|
||||||
|
ms.setActionHandler('pause', () => onPlayPause?.())
|
||||||
|
ms.setActionHandler('nexttrack', () => onNext?.())
|
||||||
|
ms.setActionHandler('previoustrack', () => onPrevious?.())
|
||||||
|
ms.setActionHandler('stop', () => onStop?.())
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetadata(step, sessionTitle) {
|
||||||
|
if (!isSupported || !step) return
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: step.exerciseTitle,
|
||||||
|
artist: step.comboTitle || sessionTitle || '',
|
||||||
|
album: sessionTitle || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlaybackState(state) {
|
||||||
|
if (!isSupported) return
|
||||||
|
navigator.mediaSession.playbackState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (!isSupported) return
|
||||||
|
const ms = navigator.mediaSession
|
||||||
|
for (const action of ['play', 'pause', 'nexttrack', 'previoustrack', 'stop']) {
|
||||||
|
ms.setActionHandler(action, null)
|
||||||
|
}
|
||||||
|
ms.playbackState = 'none'
|
||||||
|
ms.metadata = null
|
||||||
|
if (audioEl) {
|
||||||
|
audioEl.pause()
|
||||||
|
audioEl.currentTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isSupported, start, updateMetadata, setPlaybackState, stop }
|
||||||
|
}
|
||||||
|
|
@ -382,6 +382,17 @@ export function useSessionExecution(sessionId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (sessionDone.value || currentIndex.value <= 0) return
|
||||||
|
stopTimer()
|
||||||
|
timerPhase.value = null
|
||||||
|
showFinalRound.value = false
|
||||||
|
isAmrapTimeUp.value = false
|
||||||
|
currentAmrapComboId.value = null
|
||||||
|
currentIndex.value--
|
||||||
|
loadStepState()
|
||||||
|
}
|
||||||
|
|
||||||
async function _advance() {
|
async function _advance() {
|
||||||
currentIndex.value++
|
currentIndex.value++
|
||||||
if (currentIndex.value >= queue.value.length) {
|
if (currentIndex.value >= queue.value.length) {
|
||||||
|
|
@ -446,6 +457,7 @@ export function useSessionExecution(sessionId) {
|
||||||
continueAfterAmrap,
|
continueAfterAmrap,
|
||||||
addOneMoreRound,
|
addOneMoreRound,
|
||||||
togglePause,
|
togglePause,
|
||||||
|
goBack,
|
||||||
exit,
|
exit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,7 @@
|
||||||
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
||||||
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
||||||
"done": "Færdig",
|
"done": "Færdig",
|
||||||
|
"previous": "Forrige trin",
|
||||||
"continue": "Fortsæt",
|
"continue": "Fortsæt",
|
||||||
"oneMoreRound": "En runde til",
|
"oneMoreRound": "En runde til",
|
||||||
"comboContext": "Kombo: {{title}} — Runde {{round}} af {{total}}",
|
"comboContext": "Kombo: {{title}} — Runde {{round}} af {{total}}",
|
||||||
|
|
@ -436,6 +437,13 @@
|
||||||
"privateMode": "TrainUs fungerer ikke i privat/inkognitotilstand — dine data vil ikke blive gemt.",
|
"privateMode": "TrainUs fungerer ikke i privat/inkognitotilstand — dine data vil ikke blive gemt.",
|
||||||
"dismiss": "Forstået"
|
"dismiss": "Forstået"
|
||||||
},
|
},
|
||||||
|
"appUpdate": {
|
||||||
|
"title": "Opdatering tilgængelig",
|
||||||
|
"body": "En ny version af TrainUs er klar. Opdater nu, eller fortsæt og opdater senere.",
|
||||||
|
"updateNow": "Opdater nu",
|
||||||
|
"later": "Senere",
|
||||||
|
"updating": "Opdaterer…"
|
||||||
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"button": "Installer app",
|
"button": "Installer app",
|
||||||
"dismiss": "Afvis installationsprompt"
|
"dismiss": "Afvis installationsprompt"
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,7 @@
|
||||||
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
||||||
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
|
"previous": "Previous step",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"oneMoreRound": "One More Round",
|
"oneMoreRound": "One More Round",
|
||||||
"comboContext": "Combo: {{title}} — Round {{round}} of {{total}}",
|
"comboContext": "Combo: {{title}} — Round {{round}} of {{total}}",
|
||||||
|
|
@ -436,6 +437,13 @@
|
||||||
"privateMode": "TrainUs does not work in private/incognito browsing mode — your data would not be saved.",
|
"privateMode": "TrainUs does not work in private/incognito browsing mode — your data would not be saved.",
|
||||||
"dismiss": "Got it"
|
"dismiss": "Got it"
|
||||||
},
|
},
|
||||||
|
"appUpdate": {
|
||||||
|
"title": "Update available",
|
||||||
|
"body": "A new version of TrainUs is ready. Update now, or continue and update later.",
|
||||||
|
"updateNow": "Update now",
|
||||||
|
"later": "Later",
|
||||||
|
"updating": "Updating…"
|
||||||
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"button": "Install app",
|
"button": "Install app",
|
||||||
"dismiss": "Dismiss install prompt"
|
"dismiss": "Dismiss install prompt"
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,7 @@
|
||||||
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
"asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}",
|
||||||
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
"asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}",
|
||||||
"done": "Terminé",
|
"done": "Terminé",
|
||||||
|
"previous": "Étape précédente",
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"oneMoreRound": "Un tour de plus",
|
"oneMoreRound": "Un tour de plus",
|
||||||
"comboContext": "Combo : {{title}} — Tour {{round}} sur {{total}}",
|
"comboContext": "Combo : {{title}} — Tour {{round}} sur {{total}}",
|
||||||
|
|
@ -436,6 +437,13 @@
|
||||||
"privateMode": "TrainUs ne fonctionne pas en navigation privée — vos données ne seraient pas sauvegardées.",
|
"privateMode": "TrainUs ne fonctionne pas en navigation privée — vos données ne seraient pas sauvegardées.",
|
||||||
"dismiss": "J'ai compris"
|
"dismiss": "J'ai compris"
|
||||||
},
|
},
|
||||||
|
"appUpdate": {
|
||||||
|
"title": "Mise à jour disponible",
|
||||||
|
"body": "Une nouvelle version de TrainUs est prête. Mettez à jour maintenant, ou continuez et mettez à jour plus tard.",
|
||||||
|
"updateNow": "Mettre à jour",
|
||||||
|
"later": "Plus tard",
|
||||||
|
"updating": "Mise à jour en cours…"
|
||||||
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"button": "Installer l'application",
|
"button": "Installer l'application",
|
||||||
"dismiss": "Ignorer l'invitation à installer"
|
"dismiss": "Ignorer l'invitation à installer"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter, useRoute, onBeforeRouteLeave } from 'vue-router'
|
import { useRouter, useRoute, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { useTranslation } from 'i18next-vue'
|
import { useTranslation } from 'i18next-vue'
|
||||||
import { useSessionExecution } from '../composables/useSessionExecution.js'
|
import { useSessionExecution } from '../composables/useSessionExecution.js'
|
||||||
|
import { useMediaSession } from '../composables/useMediaSession.js'
|
||||||
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
|
import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
|
||||||
import { useOnlineStatus } from '../composables/useOnlineStatus.js'
|
import { useOnlineStatus } from '../composables/useOnlineStatus.js'
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
|
|
@ -39,18 +40,31 @@ const {
|
||||||
continueAfterAmrap,
|
continueAfterAmrap,
|
||||||
addOneMoreRound,
|
addOneMoreRound,
|
||||||
togglePause,
|
togglePause,
|
||||||
|
goBack,
|
||||||
exit,
|
exit,
|
||||||
} = useSessionExecution(sessionId)
|
} = useSessionExecution(sessionId)
|
||||||
|
|
||||||
|
const {
|
||||||
|
start: startMediaSession,
|
||||||
|
updateMetadata: updateMediaMetadata,
|
||||||
|
setPlaybackState: setMediaPlaybackState,
|
||||||
|
stop: stopMediaSession,
|
||||||
|
} = useMediaSession()
|
||||||
|
|
||||||
const showDetail = ref(false)
|
const showDetail = ref(false)
|
||||||
const detailExercise = ref(null)
|
const detailExercise = ref(null)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailPausedTimer = ref(false)
|
const detailPausedTimer = ref(false)
|
||||||
|
|
||||||
watch(currentStep, () => {
|
watch(currentStep, (step) => {
|
||||||
showDetail.value = false
|
showDetail.value = false
|
||||||
detailExercise.value = null
|
detailExercise.value = null
|
||||||
detailPausedTimer.value = false
|
detailPausedTimer.value = false
|
||||||
|
updateMediaMetadata(step, sessionTitle.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(sessionDone, (done) => {
|
||||||
|
if (done) stopMediaSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function toggleDetail() {
|
async function toggleDetail() {
|
||||||
|
|
@ -73,8 +87,45 @@ async function toggleDetail() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onMediaPlayPause() {
|
||||||
|
if (!timerActive.value) return
|
||||||
|
togglePause()
|
||||||
|
setMediaPlaybackState(timerPaused.value ? 'paused' : 'playing')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaNext() {
|
||||||
|
if (sessionDone.value) return
|
||||||
|
if (isAmrapTimeUp.value) {
|
||||||
|
onContinueAmrap()
|
||||||
|
} else if (showFinalRound.value) {
|
||||||
|
onContinue()
|
||||||
|
} else if (timerPhase.value !== 'rest') {
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaPrevious() {
|
||||||
|
goBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMediaStop() {
|
||||||
|
onExit()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await init()
|
await init()
|
||||||
|
if (!error.value) {
|
||||||
|
startMediaSession({
|
||||||
|
onPlayPause: onMediaPlayPause,
|
||||||
|
onNext: onMediaNext,
|
||||||
|
onPrevious: onMediaPrevious,
|
||||||
|
onStop: onMediaStop,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopMediaSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeRouteLeave(async () => {
|
onBeforeRouteLeave(async () => {
|
||||||
|
|
@ -433,10 +484,22 @@ async function onContinueAmrap() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="step-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary previous-btn"
|
||||||
|
data-testid="execute-previous-btn"
|
||||||
|
:disabled="currentIndex === 0"
|
||||||
|
:title="$t('execution.previous')"
|
||||||
|
:aria-label="$t('execution.previous')"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<i class="bi bi-skip-start-fill"></i>
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary done-btn" data-testid="execute-done-btn" @click="onDone">
|
<button class="btn btn-primary done-btn" data-testid="execute-done-btn" @click="onDone">
|
||||||
<i class="bi bi-check-lg"></i> {{ $t('execution.done') }}
|
<i class="bi bi-check-lg"></i> {{ $t('execution.done') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Next step preview -->
|
<!-- Next step preview -->
|
||||||
<template v-if="nextStep && timerPhase !== 'rest'">
|
<template v-if="nextStep && timerPhase !== 'rest'">
|
||||||
|
|
@ -759,9 +822,25 @@ async function onContinueAmrap() {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.done-btn {
|
.step-actions {
|
||||||
width: 100%;
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-btn {
|
||||||
|
flex: 1;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
176
tests/test_step24_app_update.py
Normal file
176
tests/test_step24_app_update.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""
|
||||||
|
Step 24 — App Update Modal (no more silent freeze)
|
||||||
|
|
||||||
|
- test_modal_appears_when_update_pending: Modal appears when an update is pending
|
||||||
|
- test_later_dismisses_for_session: "Later" closes the modal and it stays closed across navigation
|
||||||
|
- test_update_now_shows_spinner_and_calls_update: "Update now" switches to the spinner state and calls the update function
|
||||||
|
- test_spinner_state_ignores_escape: The updating state cannot be dismissed with Escape
|
||||||
|
- test_spinner_state_has_no_close_button: The updating state has no header close button
|
||||||
|
- test_modal_suppressed_on_execution_route: Modal does not appear during session execution
|
||||||
|
- test_modal_appears_after_leaving_execution: Modal appears once the user navigates away from execution
|
||||||
|
|
||||||
|
The pending update is forced through the dev-only hook `window.__appUpdateTest`
|
||||||
|
(see src/composables/useAppUpdate.js) — simulating a real service-worker update
|
||||||
|
in Playwright would require two production builds. The real SW flow is
|
||||||
|
validated manually.
|
||||||
|
"""
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_db(page: Page, app_url: str):
|
||||||
|
page.goto(app_url)
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||||
|
timeout=15000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_update(page: Page):
|
||||||
|
"""Force the 'update available' state via the dev-only test hook."""
|
||||||
|
page.wait_for_function("!!window.__appUpdateTest", timeout=10000)
|
||||||
|
page.evaluate("window.__appUpdateTest.trigger()")
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_update_fn(page: Page):
|
||||||
|
"""Replace the real SW update call with a recorder that never resolves
|
||||||
|
(the real call ends in a page reload, which the spinner state covers)."""
|
||||||
|
page.evaluate("""() => {
|
||||||
|
window.__updateCalls = []
|
||||||
|
window.__appUpdateTest.mockUpdateFn = (reload) => {
|
||||||
|
window.__updateCalls.push(reload)
|
||||||
|
return new Promise(() => {})
|
||||||
|
}
|
||||||
|
}""")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session(page: Page, title: str) -> str:
|
||||||
|
"""Create a one-exercise session and return its id."""
|
||||||
|
return page.evaluate(f"""async () => {{
|
||||||
|
const userId = await window.__repos.settings.get('user_uuid')
|
||||||
|
const exId = await window.__repos.exercises.create({{
|
||||||
|
title: {repr(title + ' exercise')},
|
||||||
|
description: '',
|
||||||
|
asymmetric: false,
|
||||||
|
alternate: false,
|
||||||
|
image_urls: '[]',
|
||||||
|
video_urls: '[]',
|
||||||
|
default_reps: 10,
|
||||||
|
default_weight: 0,
|
||||||
|
created_by: userId
|
||||||
|
}})
|
||||||
|
const sessId = await window.__repos.sessions.create({{
|
||||||
|
title: {repr(title)}, description: '', created_by: userId
|
||||||
|
}})
|
||||||
|
await window.__repos.sessions.setItems(sessId, [{{
|
||||||
|
item_id: exId, item_type: 'exercise',
|
||||||
|
position: 0, repetitions: 1, weight: 0
|
||||||
|
}}])
|
||||||
|
return sessId
|
||||||
|
}}""")
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_appears_when_update_pending(page: Page, app_url: str):
|
||||||
|
"""The update modal appears when an update is pending."""
|
||||||
|
_wait_for_db(page, app_url)
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="app-update-modal"]')).not_to_be_visible()
|
||||||
|
_trigger_update(page)
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="app-update-modal"]')).to_be_visible()
|
||||||
|
expect(page.locator('[data-testid="app-update-now"]')).to_be_visible()
|
||||||
|
expect(page.locator('[data-testid="app-update-later"]')).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_dismisses_for_session(page: Page, app_url: str):
|
||||||
|
"""'Later' closes the modal and it does not reappear while navigating."""
|
||||||
|
_wait_for_db(page, app_url)
|
||||||
|
_trigger_update(page)
|
||||||
|
|
||||||
|
modal = page.locator('[data-testid="app-update-modal"]')
|
||||||
|
expect(modal).to_be_visible()
|
||||||
|
|
||||||
|
page.locator('[data-testid="app-update-later"]').click()
|
||||||
|
expect(modal).not_to_be_visible()
|
||||||
|
|
||||||
|
# Navigating within the session does not bring the modal back
|
||||||
|
page.evaluate("window.location.hash = '#/exercises'")
|
||||||
|
page.wait_for_selector('[data-testid="exercise-new-btn"]', timeout=10000)
|
||||||
|
expect(modal).not_to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_now_shows_spinner_and_calls_update(page: Page, app_url: str):
|
||||||
|
"""'Update now' switches to the spinner state and calls the update function."""
|
||||||
|
_wait_for_db(page, app_url)
|
||||||
|
_trigger_update(page)
|
||||||
|
_mock_update_fn(page)
|
||||||
|
|
||||||
|
page.locator('[data-testid="app-update-now"]').click()
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="app-update-updating"]')).to_be_visible()
|
||||||
|
# Choice buttons are gone
|
||||||
|
expect(page.locator('[data-testid="app-update-now"]')).not_to_be_visible()
|
||||||
|
expect(page.locator('[data-testid="app-update-later"]')).not_to_be_visible()
|
||||||
|
|
||||||
|
calls = page.evaluate("window.__updateCalls")
|
||||||
|
assert calls == [True], f"updateServiceWorker should be called with reload=true, got {calls}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spinner_state_ignores_escape(page: Page, app_url: str):
|
||||||
|
"""The updating state cannot be dismissed with Escape or a backdrop click."""
|
||||||
|
_wait_for_db(page, app_url)
|
||||||
|
_trigger_update(page)
|
||||||
|
_mock_update_fn(page)
|
||||||
|
|
||||||
|
page.locator('[data-testid="app-update-now"]').click()
|
||||||
|
updating = page.locator('[data-testid="app-update-updating"]')
|
||||||
|
expect(updating).to_be_visible()
|
||||||
|
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
expect(updating).to_be_visible()
|
||||||
|
|
||||||
|
# Backdrop click must not close it either
|
||||||
|
page.locator('[data-testid="modal-overlay"]').click(position={"x": 5, "y": 5})
|
||||||
|
expect(updating).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_spinner_state_has_no_close_button(page: Page, app_url: str):
|
||||||
|
"""The updating state renders no header close (×) button."""
|
||||||
|
_wait_for_db(page, app_url)
|
||||||
|
_trigger_update(page)
|
||||||
|
_mock_update_fn(page)
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="modal-close"]')).to_be_visible()
|
||||||
|
page.locator('[data-testid="app-update-now"]').click()
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="app-update-updating"]')).to_be_visible()
|
||||||
|
expect(page.locator('[data-testid="modal-close"]')).not_to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_suppressed_on_execution_route(page: Page, app_url: str):
|
||||||
|
"""The modal does not appear while a session is being executed."""
|
||||||
|
_wait_for_db(page, app_url)
|
||||||
|
sess_id = _create_session(page, "Update modal session")
|
||||||
|
|
||||||
|
page.evaluate(f"window.location.hash = '#/sessions/{sess_id}/execute'")
|
||||||
|
page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000)
|
||||||
|
|
||||||
|
_trigger_update(page)
|
||||||
|
expect(page.locator('[data-testid="app-update-modal"]')).not_to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_appears_after_leaving_execution(page: Page, app_url: str):
|
||||||
|
"""The pending modal appears once the user navigates away from execution."""
|
||||||
|
_wait_for_db(page, app_url)
|
||||||
|
sess_id = _create_session(page, "Update modal deferred")
|
||||||
|
|
||||||
|
page.evaluate(f"window.location.hash = '#/sessions/{sess_id}/execute'")
|
||||||
|
page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000)
|
||||||
|
|
||||||
|
_trigger_update(page)
|
||||||
|
expect(page.locator('[data-testid="app-update-modal"]')).not_to_be_visible()
|
||||||
|
|
||||||
|
# Leave execution (accept the abandon-confirmation dialog)
|
||||||
|
page.on("dialog", lambda dialog: dialog.accept())
|
||||||
|
page.evaluate("window.location.hash = '#/home'")
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="app-update-modal"]')).to_be_visible()
|
||||||
404
tests/test_step25_media_session.py
Normal file
404
tests/test_step25_media_session.py
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
"""
|
||||||
|
Step 25 — Headset / Media-Key Control of Session Execution
|
||||||
|
|
||||||
|
`navigator.mediaSession` is replaced by a lightweight mock injected before every
|
||||||
|
page load (see `_MEDIA_SESSION_MOCK`): `setActionHandler` calls are captured
|
||||||
|
into `window.__mediaSessionMock.handlers`, and `metadata` / `playbackState`
|
||||||
|
assignments are recorded into history arrays. Tests invoke the captured
|
||||||
|
handlers directly (`window.__mediaSessionMock.handlers.nexttrack()`) to
|
||||||
|
simulate a real headset button press.
|
||||||
|
|
||||||
|
- test_nexttrack_advances_step_and_saves_log: `nexttrack` on a normal step
|
||||||
|
calls Done, advancing the queue and logging the step
|
||||||
|
- test_nexttrack_on_final_round_continues: `nexttrack` on the final-round
|
||||||
|
screen calls Continue, advancing to the next session item
|
||||||
|
- test_previoustrack_returns_to_previous_step: `previoustrack` steps back and
|
||||||
|
is a no-op on the first step
|
||||||
|
- test_onscreen_previous_button: the visible Previous button mirrors
|
||||||
|
`previoustrack` and is disabled on the first step
|
||||||
|
- test_play_pause_toggles_timer_and_playback_state: `play`/`pause` toggle an
|
||||||
|
active timer and update `mediaSession.playbackState`
|
||||||
|
- test_stop_exits_with_aborted_status: `stop` exits immediately and saves a
|
||||||
|
partial log with status `aborted`
|
||||||
|
- test_metadata_tracks_current_exercise: metadata title matches the current
|
||||||
|
exercise and updates on advance
|
||||||
|
- test_handlers_unregistered_after_exit: action handlers are cleared once the
|
||||||
|
session ends
|
||||||
|
- test_works_with_media_session_absent: execution works normally (red path)
|
||||||
|
when `navigator.mediaSession` does not exist
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
# ── Mock injected before every page load ─────────────────────────────────────
|
||||||
|
|
||||||
|
_MEDIA_SESSION_MOCK = """
|
||||||
|
window.__mediaSessionMock = {
|
||||||
|
handlers: {},
|
||||||
|
metadataHistory: [],
|
||||||
|
playbackStateHistory: [],
|
||||||
|
};
|
||||||
|
const __mockMediaSession = {
|
||||||
|
setActionHandler(action, handler) {
|
||||||
|
window.__mediaSessionMock.handlers[action] = handler;
|
||||||
|
},
|
||||||
|
set metadata(m) {
|
||||||
|
window.__mediaSessionMock.metadataHistory.push(
|
||||||
|
m ? { title: m.title, artist: m.artist, album: m.album } : null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
get metadata() {
|
||||||
|
const h = window.__mediaSessionMock.metadataHistory;
|
||||||
|
return h.length ? h[h.length - 1] : null;
|
||||||
|
},
|
||||||
|
set playbackState(s) {
|
||||||
|
window.__mediaSessionMock.playbackStateHistory.push(s);
|
||||||
|
},
|
||||||
|
get playbackState() {
|
||||||
|
const h = window.__mediaSessionMock.playbackStateHistory;
|
||||||
|
return h.length ? h[h.length - 1] : 'none';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Object.defineProperty(navigator, 'mediaSession', {
|
||||||
|
configurable: true,
|
||||||
|
value: __mockMediaSession,
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MEDIA_SESSION_ABSENT = """
|
||||||
|
delete Navigator.prototype.mediaSession;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(page: Page, app_url: str, mock_media_session=True):
|
||||||
|
if mock_media_session:
|
||||||
|
page.add_init_script(_MEDIA_SESSION_MOCK)
|
||||||
|
else:
|
||||||
|
page.add_init_script(_MEDIA_SESSION_ABSENT)
|
||||||
|
page.goto(app_url)
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.documentElement.getAttribute('data-db-ready') === 'true'",
|
||||||
|
timeout=15000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_exercise(page: Page, title: str) -> str:
|
||||||
|
return page.evaluate(f"""async () => {{
|
||||||
|
const userId = await window.__repos.settings.get('user_uuid')
|
||||||
|
return window.__repos.exercises.create({{
|
||||||
|
title: {repr(title)}, description: '', asymmetric: false, alternate: false,
|
||||||
|
image_urls: '[]', video_urls: '[]',
|
||||||
|
default_reps: 10, default_weight: 0, created_by: userId
|
||||||
|
}})
|
||||||
|
}}""")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_combo(page: Page, title: str, combo_type: str, exercise_ids: list,
|
||||||
|
timer_config: dict = None):
|
||||||
|
tc_json = json.dumps(timer_config or {})
|
||||||
|
ex_json = json.dumps([{"exerciseId": eid, "defaultReps": 10, "defaultWeight": 0}
|
||||||
|
for eid in exercise_ids])
|
||||||
|
return page.evaluate(f"""async () => {{
|
||||||
|
const userId = await window.__repos.settings.get('user_uuid')
|
||||||
|
const comboId = await window.__repos.combos.create({{
|
||||||
|
title: {repr(title)}, description: '', type: {repr(combo_type)},
|
||||||
|
timer_config: {tc_json}, created_by: userId
|
||||||
|
}})
|
||||||
|
await window.__repos.combos.setExercises(comboId, {ex_json})
|
||||||
|
return comboId
|
||||||
|
}}""")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session_with_items(page: Page, title: str, items: list) -> str:
|
||||||
|
items_json = json.dumps(items)
|
||||||
|
return page.evaluate(f"""async () => {{
|
||||||
|
const userId = await window.__repos.settings.get('user_uuid')
|
||||||
|
const sessId = await window.__repos.sessions.create({{
|
||||||
|
title: {repr(title)}, description: '', created_by: userId
|
||||||
|
}})
|
||||||
|
const items = {items_json}
|
||||||
|
await window.__repos.sessions.setItems(sessId, items.map((it, i) => ({{
|
||||||
|
item_id: it.item_id, item_type: it.item_type,
|
||||||
|
position: i, repetitions: it.repetitions, weight: it.weight || 0
|
||||||
|
}})))
|
||||||
|
return sessId
|
||||||
|
}}""")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_all(page: Page):
|
||||||
|
page.evaluate("""async () => {
|
||||||
|
const logs = await window.__repos.executionLogs.getAll()
|
||||||
|
for (const l of logs) await window.__repos.executionLogs.delete(l.id)
|
||||||
|
const sessions = await window.__repos.sessions.getAll()
|
||||||
|
for (const s of sessions) await window.__repos.sessions.delete(s.id)
|
||||||
|
const combos = await window.__repos.combos.getAll()
|
||||||
|
for (const c of combos) await window.__repos.combos.delete(c.id)
|
||||||
|
const exercises = await window.__repos.exercises.getAll()
|
||||||
|
for (const e of exercises) await window.__repos.exercises.delete(e.id)
|
||||||
|
}""")
|
||||||
|
|
||||||
|
|
||||||
|
def _navigate_to_execute(page: Page, app_url: str, sess_id: str):
|
||||||
|
page.goto(f"{app_url}#/sessions/{sess_id}/execute")
|
||||||
|
page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_handlers(page: Page):
|
||||||
|
page.wait_for_function(
|
||||||
|
"!!(window.__mediaSessionMock && window.__mediaSessionMock.handlers.nexttrack)",
|
||||||
|
timeout=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_handler(page: Page, action: str):
|
||||||
|
page.evaluate(f"async () => await window.__mediaSessionMock.handlers['{action}']()")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexttrack_advances_step_and_saves_log(page: Page, app_url: str):
|
||||||
|
"""`nexttrack` on a normal step advances the queue and logs the step."""
|
||||||
|
_setup(page, app_url)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
ex1 = _create_exercise(page, "MediaSession Ex1")
|
||||||
|
ex2 = _create_exercise(page, "MediaSession Ex2")
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession Session", [
|
||||||
|
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
|
||||||
|
{"item_id": ex2, "item_type": "exercise", "repetitions": 5},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
_wait_for_handlers(page)
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("MediaSession Ex1")
|
||||||
|
|
||||||
|
_call_handler(page, "nexttrack")
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("MediaSession Ex2")
|
||||||
|
|
||||||
|
logs = page.evaluate("window.__repos.executionLogs.getAll()")
|
||||||
|
assert len(logs) == 1
|
||||||
|
items = page.evaluate(f"window.__repos.executionLogs.getItems({json.dumps(logs[0]['id'])})")
|
||||||
|
assert len(items) == 1, f"Expected 1 logged item, got {items}"
|
||||||
|
assert items[0]["exercise_id"] == ex1
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexttrack_on_final_round_continues(page: Page, app_url: str):
|
||||||
|
"""`nexttrack` on the final-round screen continues to the next session item."""
|
||||||
|
_setup(page, app_url)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
ex1 = _create_exercise(page, "MediaSession Combo Ex1")
|
||||||
|
ex2 = _create_exercise(page, "MediaSession Combo Ex2")
|
||||||
|
trailing = _create_exercise(page, "MediaSession Trailing")
|
||||||
|
combo_id = _create_combo(page, "MediaSession Combo", "NONE", [ex1, ex2])
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession Combo Session", [
|
||||||
|
{"item_id": combo_id, "item_type": "combo", "repetitions": 1},
|
||||||
|
{"item_id": trailing, "item_type": "exercise", "repetitions": 5},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
_wait_for_handlers(page)
|
||||||
|
|
||||||
|
_call_handler(page, "nexttrack") # Ex1 done -> Ex2
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
|
||||||
|
"MediaSession Combo Ex2"
|
||||||
|
)
|
||||||
|
|
||||||
|
_call_handler(page, "nexttrack") # Ex2 done (final) -> final-round screen
|
||||||
|
expect(page.locator('[data-testid="execute-final-round"]')).to_be_visible()
|
||||||
|
|
||||||
|
_call_handler(page, "nexttrack") # Continue -> trailing exercise
|
||||||
|
expect(page.locator('[data-testid="execute-final-round"]')).not_to_be_visible()
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
|
||||||
|
"MediaSession Trailing"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_previoustrack_returns_to_previous_step(page: Page, app_url: str):
|
||||||
|
"""`previoustrack` steps back to the previous step and is a no-op at index 0."""
|
||||||
|
_setup(page, app_url)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
ex1 = _create_exercise(page, "MediaSession Prev Ex1")
|
||||||
|
ex2 = _create_exercise(page, "MediaSession Prev Ex2")
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession Prev Session", [
|
||||||
|
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
|
||||||
|
{"item_id": ex2, "item_type": "exercise", "repetitions": 5},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
_wait_for_handlers(page)
|
||||||
|
|
||||||
|
# No-op at the first step
|
||||||
|
_call_handler(page, "previoustrack")
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
|
||||||
|
"MediaSession Prev Ex1"
|
||||||
|
)
|
||||||
|
|
||||||
|
_call_handler(page, "nexttrack")
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
|
||||||
|
"MediaSession Prev Ex2"
|
||||||
|
)
|
||||||
|
|
||||||
|
_call_handler(page, "previoustrack")
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
|
||||||
|
"MediaSession Prev Ex1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_onscreen_previous_button(page: Page, app_url: str):
|
||||||
|
"""The visible Previous button mirrors `previoustrack` and is disabled on the first step."""
|
||||||
|
_setup(page, app_url)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
ex1 = _create_exercise(page, "MediaSession Btn Ex1")
|
||||||
|
ex2 = _create_exercise(page, "MediaSession Btn Ex2")
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession Btn Session", [
|
||||||
|
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
|
||||||
|
{"item_id": ex2, "item_type": "exercise", "repetitions": 5},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
|
||||||
|
prev_btn = page.locator('[data-testid="execute-previous-btn"]')
|
||||||
|
expect(prev_btn).to_be_visible()
|
||||||
|
expect(prev_btn).to_be_disabled()
|
||||||
|
|
||||||
|
page.click('[data-testid="execute-done-btn"]')
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
|
||||||
|
"MediaSession Btn Ex2"
|
||||||
|
)
|
||||||
|
expect(prev_btn).to_be_enabled()
|
||||||
|
|
||||||
|
prev_btn.click()
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
|
||||||
|
"MediaSession Btn Ex1"
|
||||||
|
)
|
||||||
|
expect(prev_btn).to_be_disabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_pause_toggles_timer_and_playback_state(page: Page, app_url: str):
|
||||||
|
"""`play`/`pause` toggle an active timer and update `mediaSession.playbackState`."""
|
||||||
|
_setup(page, app_url)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
ex_id = _create_exercise(page, "MediaSession EMOM Ex")
|
||||||
|
combo_id = _create_combo(page, "MediaSession EMOM", "EMOM", [ex_id])
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession EMOM Session", [
|
||||||
|
{"item_id": combo_id, "item_type": "combo", "repetitions": 1},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
_wait_for_handlers(page)
|
||||||
|
|
||||||
|
_call_handler(page, "pause")
|
||||||
|
state = page.evaluate("navigator.mediaSession.playbackState")
|
||||||
|
assert state == "paused", f"Expected 'paused', got {state}"
|
||||||
|
expect(page.locator(".timer-pause-btn i")).to_have_class("bi bi-play-fill")
|
||||||
|
|
||||||
|
_call_handler(page, "play")
|
||||||
|
state = page.evaluate("navigator.mediaSession.playbackState")
|
||||||
|
assert state == "playing", f"Expected 'playing', got {state}"
|
||||||
|
expect(page.locator(".timer-pause-btn i")).to_have_class("bi bi-pause-fill")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_exits_with_aborted_status(page: Page, app_url: str):
|
||||||
|
"""`stop` exits the session immediately and saves a partial log with status `aborted`."""
|
||||||
|
_setup(page, app_url)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
ex_id = _create_exercise(page, "MediaSession Stop Ex")
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession Stop Session", [
|
||||||
|
{"item_id": ex_id, "item_type": "exercise", "repetitions": 5},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
_wait_for_handlers(page)
|
||||||
|
|
||||||
|
_call_handler(page, "stop")
|
||||||
|
page.wait_for_function(
|
||||||
|
f"window.location.hash.includes('/sessions/{sess_id}') && !window.location.hash.includes('/execute')",
|
||||||
|
timeout=5000,
|
||||||
|
)
|
||||||
|
|
||||||
|
logs = page.evaluate("window.__repos.executionLogs.getAll()")
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0]["status"] == "aborted", f"Expected aborted, got {logs[0]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_tracks_current_exercise(page: Page, app_url: str):
|
||||||
|
"""Metadata title matches the current exercise and updates on advance."""
|
||||||
|
_setup(page, app_url)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
ex1 = _create_exercise(page, "MediaSession Meta Ex1")
|
||||||
|
ex2 = _create_exercise(page, "MediaSession Meta Ex2")
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession Meta Session", [
|
||||||
|
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
|
||||||
|
{"item_id": ex2, "item_type": "exercise", "repetitions": 5},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
_wait_for_handlers(page)
|
||||||
|
|
||||||
|
meta = page.evaluate("navigator.mediaSession.metadata")
|
||||||
|
assert meta["title"] == "MediaSession Meta Ex1", f"Unexpected metadata: {meta}"
|
||||||
|
|
||||||
|
page.click('[data-testid="execute-done-btn"]')
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
|
||||||
|
meta = page.evaluate("navigator.mediaSession.metadata")
|
||||||
|
assert meta["title"] == "MediaSession Meta Ex2", f"Unexpected metadata: {meta}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handlers_unregistered_after_exit(page: Page, app_url: str):
|
||||||
|
"""Action handlers are cleared once the session ends."""
|
||||||
|
_setup(page, app_url)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
ex_id = _create_exercise(page, "MediaSession Unreg Ex")
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession Unreg Session", [
|
||||||
|
{"item_id": ex_id, "item_type": "exercise", "repetitions": 5},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
_wait_for_handlers(page)
|
||||||
|
|
||||||
|
page.click('[data-testid="execute-exit-btn"]')
|
||||||
|
page.wait_for_function(
|
||||||
|
f"window.location.hash.includes('/sessions/{sess_id}') && !window.location.hash.includes('/execute')",
|
||||||
|
timeout=5000,
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
|
||||||
|
handlers = page.evaluate("window.__mediaSessionMock.handlers")
|
||||||
|
for action in ["play", "pause", "nexttrack", "previoustrack", "stop"]:
|
||||||
|
assert handlers.get(action) is None, f"Handler for '{action}' should be null after exit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_works_with_media_session_absent(page: Page, app_url: str):
|
||||||
|
"""Execution works normally when `navigator.mediaSession` does not exist (red path)."""
|
||||||
|
_setup(page, app_url, mock_media_session=False)
|
||||||
|
_clean_all(page)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
page.on("pageerror", lambda exc: errors.append(str(exc)))
|
||||||
|
|
||||||
|
ex_id = _create_exercise(page, "MediaSession Absent Ex")
|
||||||
|
sess_id = _create_session_with_items(page, "MediaSession Absent Session", [
|
||||||
|
{"item_id": ex_id, "item_type": "exercise", "repetitions": 5},
|
||||||
|
])
|
||||||
|
_navigate_to_execute(page, app_url, sess_id)
|
||||||
|
|
||||||
|
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
|
||||||
|
"MediaSession Absent Ex"
|
||||||
|
)
|
||||||
|
page.click('[data-testid="execute-done-btn"]')
|
||||||
|
page.wait_for_function(
|
||||||
|
f"window.location.hash.includes('/sessions/{sess_id}') && !window.location.hash.includes('/execute')",
|
||||||
|
timeout=5000,
|
||||||
|
)
|
||||||
|
|
||||||
|
logs = page.evaluate("window.__repos.executionLogs.getAll()")
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0]["status"] == "completed"
|
||||||
|
assert errors == [], f"Unexpected page errors: {errors}"
|
||||||
|
|
@ -1,836 +0,0 @@
|
||||||
{
|
|
||||||
"agents": [
|
|
||||||
{
|
|
||||||
"agent": "Build",
|
|
||||||
"clients": "opencode",
|
|
||||||
"cost": 80.35733614300014,
|
|
||||||
"messageCount": 1943,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 131494231,
|
|
||||||
"cacheWrite": 5975709,
|
|
||||||
"input": 18961214,
|
|
||||||
"output": 810636,
|
|
||||||
"total": 157265517
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent": "Explore",
|
|
||||||
"clients": "claude, opencode",
|
|
||||||
"cost": 15.377813074999988,
|
|
||||||
"messageCount": 306,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 7340290,
|
|
||||||
"cacheWrite": 937849,
|
|
||||||
"input": 2021878,
|
|
||||||
"output": 176576,
|
|
||||||
"total": 10478887
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent": "Plan",
|
|
||||||
"clients": "opencode",
|
|
||||||
"cost": 6.213292899999999,
|
|
||||||
"messageCount": 98,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 2122656,
|
|
||||||
"cacheWrite": 187913,
|
|
||||||
"input": 1022055,
|
|
||||||
"output": 50446,
|
|
||||||
"total": 3383959
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent": "Compaction",
|
|
||||||
"clients": "opencode",
|
|
||||||
"cost": 4.1193574,
|
|
||||||
"messageCount": 9,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 163,
|
|
||||||
"cacheWrite": 130330,
|
|
||||||
"input": 699367,
|
|
||||||
"output": 23311,
|
|
||||||
"total": 853171
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent": "Test Runner",
|
|
||||||
"clients": "opencode",
|
|
||||||
"cost": 2.8917479999999993,
|
|
||||||
"messageCount": 282,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 1793541,
|
|
||||||
"cacheWrite": 108046,
|
|
||||||
"input": 98653,
|
|
||||||
"output": 33057,
|
|
||||||
"total": 2033297
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent": "Docs Writer",
|
|
||||||
"clients": "opencode",
|
|
||||||
"cost": 2.5088409999999994,
|
|
||||||
"messageCount": 36,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 1546392,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 157439,
|
|
||||||
"output": 37938,
|
|
||||||
"total": 1741769
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent": "General",
|
|
||||||
"clients": "opencode",
|
|
||||||
"cost": 0.539935,
|
|
||||||
"messageCount": 12,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 290780,
|
|
||||||
"cacheWrite": 29032,
|
|
||||||
"input": 9249,
|
|
||||||
"output": 6674,
|
|
||||||
"total": 335735
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"daily": [
|
|
||||||
{
|
|
||||||
"cost": 0.41224720000000004,
|
|
||||||
"date": "2026-07-03",
|
|
||||||
"messageCount": 22,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 940096,
|
|
||||||
"cacheWrite": 38328,
|
|
||||||
"input": 10969,
|
|
||||||
"output": 10647,
|
|
||||||
"total": 1000040
|
|
||||||
},
|
|
||||||
"turnCount": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.256831,
|
|
||||||
"date": "2026-07-01",
|
|
||||||
"messageCount": 15,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 462553,
|
|
||||||
"cacheWrite": 31923,
|
|
||||||
"input": 11163,
|
|
||||||
"output": 5206,
|
|
||||||
"total": 510845
|
|
||||||
},
|
|
||||||
"turnCount": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.9797120999999999,
|
|
||||||
"date": "2026-06-30",
|
|
||||||
"messageCount": 10,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 1180532,
|
|
||||||
"cacheWrite": 146982,
|
|
||||||
"input": 7345,
|
|
||||||
"output": 3489,
|
|
||||||
"total": 1338348
|
|
||||||
},
|
|
||||||
"turnCount": 6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 3.964411649999996,
|
|
||||||
"date": "2026-06-29",
|
|
||||||
"messageCount": 120,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 8018313,
|
|
||||||
"cacheWrite": 166797,
|
|
||||||
"input": 21158,
|
|
||||||
"output": 57997,
|
|
||||||
"total": 8264265
|
|
||||||
},
|
|
||||||
"turnCount": 16
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 14.114278349999996,
|
|
||||||
"date": "2026-06-18",
|
|
||||||
"messageCount": 444,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 26520977,
|
|
||||||
"cacheWrite": 772275,
|
|
||||||
"input": 176558,
|
|
||||||
"output": 182152,
|
|
||||||
"total": 27651962
|
|
||||||
},
|
|
||||||
"turnCount": 55
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.3690055499999999,
|
|
||||||
"date": "2026-06-17",
|
|
||||||
"messageCount": 14,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 389926,
|
|
||||||
"cacheWrite": 27109,
|
|
||||||
"input": 6833,
|
|
||||||
"output": 8658,
|
|
||||||
"total": 432526
|
|
||||||
},
|
|
||||||
"turnCount": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 64.53090050000012,
|
|
||||||
"date": "2026-06-16",
|
|
||||||
"messageCount": 917,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 151918831,
|
|
||||||
"cacheWrite": 1987112,
|
|
||||||
"input": 392384,
|
|
||||||
"output": 701638,
|
|
||||||
"total": 154999965
|
|
||||||
},
|
|
||||||
"turnCount": 41
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 9.405165150000006,
|
|
||||||
"date": "2026-06-14",
|
|
||||||
"messageCount": 232,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 14530218,
|
|
||||||
"cacheWrite": 434357,
|
|
||||||
"input": 71732,
|
|
||||||
"output": 213471,
|
|
||||||
"total": 15249778
|
|
||||||
},
|
|
||||||
"turnCount": 29
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.19272855000000003,
|
|
||||||
"date": "2026-06-13",
|
|
||||||
"messageCount": 7,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 156186,
|
|
||||||
"cacheWrite": 15437,
|
|
||||||
"input": 1413,
|
|
||||||
"output": 5583,
|
|
||||||
"total": 178619
|
|
||||||
},
|
|
||||||
"turnCount": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 28.31693669999997,
|
|
||||||
"date": "2026-06-12",
|
|
||||||
"messageCount": 390,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 39048117,
|
|
||||||
"cacheWrite": 692539,
|
|
||||||
"input": 174952,
|
|
||||||
"output": 184656,
|
|
||||||
"total": 40100264
|
|
||||||
},
|
|
||||||
"turnCount": 11
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 11.713803999999998,
|
|
||||||
"date": "2026-06-11",
|
|
||||||
"messageCount": 92,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 3835309,
|
|
||||||
"cacheWrite": 212878,
|
|
||||||
"input": 74617,
|
|
||||||
"output": 89427,
|
|
||||||
"total": 4212231
|
|
||||||
},
|
|
||||||
"turnCount": 12
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 4.840531199999996,
|
|
||||||
"date": "2026-06-09",
|
|
||||||
"messageCount": 119,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 9479214,
|
|
||||||
"cacheWrite": 259180,
|
|
||||||
"input": 49729,
|
|
||||||
"output": 58377,
|
|
||||||
"total": 9846500
|
|
||||||
},
|
|
||||||
"turnCount": 15
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 4.235904949999998,
|
|
||||||
"date": "2026-06-08",
|
|
||||||
"messageCount": 155,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 8223499,
|
|
||||||
"cacheWrite": 244635,
|
|
||||||
"input": 132257,
|
|
||||||
"output": 50623,
|
|
||||||
"total": 8651014
|
|
||||||
},
|
|
||||||
"turnCount": 14
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 2.9825825999999993,
|
|
||||||
"date": "2026-06-07",
|
|
||||||
"messageCount": 61,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 4897682,
|
|
||||||
"cacheWrite": 173280,
|
|
||||||
"input": 86796,
|
|
||||||
"output": 40206,
|
|
||||||
"total": 5197964
|
|
||||||
},
|
|
||||||
"turnCount": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.9010546500000001,
|
|
||||||
"date": "2026-06-06",
|
|
||||||
"messageCount": 19,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 920393,
|
|
||||||
"cacheWrite": 51349,
|
|
||||||
"input": 5716,
|
|
||||||
"output": 27682,
|
|
||||||
"total": 1005140
|
|
||||||
},
|
|
||||||
"turnCount": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 1.0923621000000003,
|
|
||||||
"date": "2026-06-05",
|
|
||||||
"messageCount": 19,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 1795697,
|
|
||||||
"cacheWrite": 101788,
|
|
||||||
"input": 7911,
|
|
||||||
"output": 9881,
|
|
||||||
"total": 1915277
|
|
||||||
},
|
|
||||||
"turnCount": 6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 1.78596485,
|
|
||||||
"date": "2026-06-04",
|
|
||||||
"messageCount": 62,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 2786625,
|
|
||||||
"cacheWrite": 157699,
|
|
||||||
"input": 76411,
|
|
||||||
"output": 40869,
|
|
||||||
"total": 3061604
|
|
||||||
},
|
|
||||||
"turnCount": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 13.23304699999999,
|
|
||||||
"date": "2026-06-02",
|
|
||||||
"messageCount": 395,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 20408906,
|
|
||||||
"cacheWrite": 718488,
|
|
||||||
"input": 198263,
|
|
||||||
"output": 268343,
|
|
||||||
"total": 21594000
|
|
||||||
},
|
|
||||||
"turnCount": 51
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 3.121295999999995,
|
|
||||||
"date": "2026-06-01",
|
|
||||||
"messageCount": 118,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 5457665,
|
|
||||||
"cacheWrite": 170098,
|
|
||||||
"input": 49743,
|
|
||||||
"output": 46460,
|
|
||||||
"total": 5723966
|
|
||||||
},
|
|
||||||
"turnCount": 11
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 9.753698750000005,
|
|
||||||
"date": "2026-05-31",
|
|
||||||
"messageCount": 308,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 17093630,
|
|
||||||
"cacheWrite": 665323,
|
|
||||||
"input": 179179,
|
|
||||||
"output": 154587,
|
|
||||||
"total": 18092719
|
|
||||||
},
|
|
||||||
"turnCount": 31
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 6.757524749999998,
|
|
||||||
"date": "2026-05-19",
|
|
||||||
"messageCount": 107,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 6203435,
|
|
||||||
"cacheWrite": 1161211,
|
|
||||||
"input": 121,
|
|
||||||
"output": 36106,
|
|
||||||
"total": 7400873
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 3.7578223499999983,
|
|
||||||
"date": "2026-05-18",
|
|
||||||
"messageCount": 139,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 5767382,
|
|
||||||
"cacheWrite": 358113,
|
|
||||||
"input": 153,
|
|
||||||
"output": 45615,
|
|
||||||
"total": 6171263
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 11.007388399999998,
|
|
||||||
"date": "2026-05-14",
|
|
||||||
"messageCount": 105,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 7369852,
|
|
||||||
"cacheWrite": 952046,
|
|
||||||
"input": 170,
|
|
||||||
"output": 82628,
|
|
||||||
"total": 8404696
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 4.351449700000002,
|
|
||||||
"date": "2026-05-11",
|
|
||||||
"messageCount": 489,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 44887569,
|
|
||||||
"cacheWrite": 2216290,
|
|
||||||
"input": 2916,
|
|
||||||
"output": 240144,
|
|
||||||
"total": 47346919
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.073390968,
|
|
||||||
"date": "2026-05-05",
|
|
||||||
"messageCount": 69,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 4126080,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 95594,
|
|
||||||
"output": 16161,
|
|
||||||
"total": 4241161
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.21754622500000007,
|
|
||||||
"date": "2026-04-28",
|
|
||||||
"messageCount": 17,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 0,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 640531,
|
|
||||||
"output": 3839,
|
|
||||||
"total": 645338
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 12.0778915,
|
|
||||||
"date": "2026-04-22",
|
|
||||||
"messageCount": 263,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 9078065,
|
|
||||||
"cacheWrite": 271228,
|
|
||||||
"input": 15916614,
|
|
||||||
"output": 82088,
|
|
||||||
"total": 25355387
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.7519993749999998,
|
|
||||||
"date": "2026-04-21",
|
|
||||||
"messageCount": 105,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 6883685,
|
|
||||||
"cacheWrite": 466509,
|
|
||||||
"input": 630,
|
|
||||||
"output": 32472,
|
|
||||||
"total": 7389468
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 9.739795749999997,
|
|
||||||
"date": "2026-04-20",
|
|
||||||
"messageCount": 251,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 22020930,
|
|
||||||
"cacheWrite": 1315006,
|
|
||||||
"input": 940,
|
|
||||||
"output": 99941,
|
|
||||||
"total": 23445869
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 4.481086,
|
|
||||||
"date": "2026-04-18",
|
|
||||||
"messageCount": 61,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 3282992,
|
|
||||||
"cacheWrite": 309752,
|
|
||||||
"input": 93,
|
|
||||||
"output": 36127,
|
|
||||||
"total": 3628964
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 0.479889,
|
|
||||||
"date": "2026-04-03",
|
|
||||||
"messageCount": 12,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 207648,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 59338,
|
|
||||||
"output": 3175,
|
|
||||||
"total": 270161
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 13.705962000000003,
|
|
||||||
"date": "2026-03-31",
|
|
||||||
"messageCount": 141,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 5836294,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 1644138,
|
|
||||||
"output": 102685,
|
|
||||||
"total": 7583117
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 11.707100999999986,
|
|
||||||
"date": "2026-03-30",
|
|
||||||
"messageCount": 190,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 9793672,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 784418,
|
|
||||||
"output": 115527,
|
|
||||||
"total": 10693617
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 9.132739,
|
|
||||||
"date": "2026-03-28",
|
|
||||||
"messageCount": 162,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 9189318,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 452331,
|
|
||||||
"output": 91057,
|
|
||||||
"total": 9732706
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 18.31148299999999,
|
|
||||||
"date": "2026-03-27",
|
|
||||||
"messageCount": 420,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 6586206,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 2521761,
|
|
||||||
"output": 96383,
|
|
||||||
"total": 9204350
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 1.6884175,
|
|
||||||
"date": "2026-03-26",
|
|
||||||
"messageCount": 13,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 283625,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 264001,
|
|
||||||
"output": 9064,
|
|
||||||
"total": 556690
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cost": 2.8088324999999994,
|
|
||||||
"date": "2026-03-25",
|
|
||||||
"messageCount": 29,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 515025,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 426574,
|
|
||||||
"output": 16738,
|
|
||||||
"total": 958337
|
|
||||||
},
|
|
||||||
"turnCount": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"models": [
|
|
||||||
{
|
|
||||||
"client": "opencode, claude",
|
|
||||||
"cost": 154.14335354999952,
|
|
||||||
"model": "claude-sonnet-4-6",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 131.27891702972073,
|
|
||||||
"sampleCount": 3346,
|
|
||||||
"timedTokens": 319584172,
|
|
||||||
"tokenCoverage": 0.9957107665667683,
|
|
||||||
"totalDurationMs": 41954664
|
|
||||||
},
|
|
||||||
"provider": "opencode, anthropic",
|
|
||||||
"sessionCount": 82,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 310413071,
|
|
||||||
"cacheWrite": 7134279,
|
|
||||||
"input": 1411382,
|
|
||||||
"output": 2002116,
|
|
||||||
"total": 320960848
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "opencode",
|
|
||||||
"cost": 68.75964850000005,
|
|
||||||
"model": "claude-opus-4-6",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 678.703267189516,
|
|
||||||
"sampleCount": 915,
|
|
||||||
"timedTokens": 47011751,
|
|
||||||
"tokenCoverage": 1.0,
|
|
||||||
"totalDurationMs": 31907029
|
|
||||||
},
|
|
||||||
"provider": "github_copilot, opencode",
|
|
||||||
"sessionCount": 166,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 39461287,
|
|
||||||
"cacheWrite": 889556,
|
|
||||||
"input": 6152671,
|
|
||||||
"output": 508237,
|
|
||||||
"total": 47011751
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "claude",
|
|
||||||
"cost": 27.014924499999996,
|
|
||||||
"model": "claude-fable-5",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 260.22168923268407,
|
|
||||||
"sampleCount": 200,
|
|
||||||
"timedTokens": 11958542,
|
|
||||||
"tokenCoverage": 0.9921450858895039,
|
|
||||||
"totalDurationMs": 3111872
|
|
||||||
},
|
|
||||||
"provider": "anthropic",
|
|
||||||
"sessionCount": 5,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 11279302,
|
|
||||||
"cacheWrite": 460453,
|
|
||||||
"input": 142331,
|
|
||||||
"output": 171133,
|
|
||||||
"total": 12053219
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "opencode",
|
|
||||||
"cost": 12.828726000000001,
|
|
||||||
"model": "claude-opus-4-7",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 440.04060995500197,
|
|
||||||
"sampleCount": 178,
|
|
||||||
"timedTokens": 12511218,
|
|
||||||
"tokenCoverage": 1.0,
|
|
||||||
"totalDurationMs": 5505444
|
|
||||||
},
|
|
||||||
"provider": "opencode",
|
|
||||||
"sessionCount": 5,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 11638787,
|
|
||||||
"cacheWrite": 789166,
|
|
||||||
"input": 229,
|
|
||||||
"output": 83036,
|
|
||||||
"total": 12511218
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "opencode",
|
|
||||||
"cost": 6.772487500000001,
|
|
||||||
"model": "anthropic/claude-opus-4-6",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 188.6804191325956,
|
|
||||||
"sampleCount": 58,
|
|
||||||
"timedTokens": 9370877,
|
|
||||||
"tokenCoverage": 1.0,
|
|
||||||
"totalDurationMs": 1768101
|
|
||||||
},
|
|
||||||
"provider": "openrouter",
|
|
||||||
"sessionCount": 3,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 9078065,
|
|
||||||
"cacheWrite": 271228,
|
|
||||||
"input": 66,
|
|
||||||
"output": 21518,
|
|
||||||
"total": 9370877
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "opencode",
|
|
||||||
"cost": 6.577768725,
|
|
||||||
"model": "qwen3.6-plus",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 278.94389486299883,
|
|
||||||
"sampleCount": 735,
|
|
||||||
"timedTokens": 69691925,
|
|
||||||
"tokenCoverage": 1.0,
|
|
||||||
"totalDurationMs": 19440137
|
|
||||||
},
|
|
||||||
"provider": "opencode",
|
|
||||||
"sessionCount": 12,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 65756742,
|
|
||||||
"cacheWrite": 3580881,
|
|
||||||
"input": 4410,
|
|
||||||
"output": 334668,
|
|
||||||
"total": 69691925
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "opencode",
|
|
||||||
"cost": 5.522950225000005,
|
|
||||||
"model": "qwen/qwen3.6-plus",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 338.02756345097083,
|
|
||||||
"sampleCount": 217,
|
|
||||||
"timedTokens": 16629848,
|
|
||||||
"tokenCoverage": 1.0,
|
|
||||||
"totalDurationMs": 5621347
|
|
||||||
},
|
|
||||||
"provider": "openrouter",
|
|
||||||
"sessionCount": 5,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 0,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 16557079,
|
|
||||||
"output": 64409,
|
|
||||||
"total": 16629848
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "opencode",
|
|
||||||
"cost": 3.962829899999999,
|
|
||||||
"model": "anthropic/claude-sonnet-4-6",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 101.17965789503508,
|
|
||||||
"sampleCount": 52,
|
|
||||||
"timedTokens": 5029509,
|
|
||||||
"tokenCoverage": 1.0,
|
|
||||||
"totalDurationMs": 508884
|
|
||||||
},
|
|
||||||
"provider": "openrouter",
|
|
||||||
"sessionCount": 1,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 4395908,
|
|
||||||
"cacheWrite": 609710,
|
|
||||||
"input": 60,
|
|
||||||
"output": 23831,
|
|
||||||
"total": 5029509
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "claude",
|
|
||||||
"cost": 0.9580045000000003,
|
|
||||||
"model": "claude-haiku-4-5",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 130.6431946244881,
|
|
||||||
"sampleCount": 113,
|
|
||||||
"timedTokens": 2909788,
|
|
||||||
"tokenCoverage": 0.949849824656699,
|
|
||||||
"totalDurationMs": 380144
|
|
||||||
},
|
|
||||||
"provider": "anthropic",
|
|
||||||
"sessionCount": 7,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 2556275,
|
|
||||||
"cacheWrite": 318724,
|
|
||||||
"input": 159532,
|
|
||||||
"output": 28888,
|
|
||||||
"total": 3063419
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "claude",
|
|
||||||
"cost": 0.6386975000000001,
|
|
||||||
"model": "claude-sonnet-5",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 171.06539394692618,
|
|
||||||
"sampleCount": 36,
|
|
||||||
"timedTokens": 1480504,
|
|
||||||
"tokenCoverage": 0.9921871753871229,
|
|
||||||
"totalDurationMs": 253263
|
|
||||||
},
|
|
||||||
"provider": "anthropic",
|
|
||||||
"sessionCount": 3,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 1390630,
|
|
||||||
"cacheWrite": 63735,
|
|
||||||
"input": 22092,
|
|
||||||
"output": 15705,
|
|
||||||
"total": 1492162
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "opencode",
|
|
||||||
"cost": 0.073390968,
|
|
||||||
"model": "xiaomi/mimo-v2.5-pro",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": 631.3860756523981,
|
|
||||||
"sampleCount": 68,
|
|
||||||
"timedTokens": 4241161,
|
|
||||||
"tokenCoverage": 1.0,
|
|
||||||
"totalDurationMs": 2677810
|
|
||||||
},
|
|
||||||
"provider": "openrouter",
|
|
||||||
"sessionCount": 1,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 4126080,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 95594,
|
|
||||||
"output": 16161,
|
|
||||||
"total": 4241161
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "claude",
|
|
||||||
"cost": 0.0,
|
|
||||||
"model": "<synthetic>",
|
|
||||||
"performance": {
|
|
||||||
"msPer1KTokens": null,
|
|
||||||
"sampleCount": 0,
|
|
||||||
"timedTokens": 0,
|
|
||||||
"tokenCoverage": 0.0,
|
|
||||||
"totalDurationMs": 0
|
|
||||||
},
|
|
||||||
"provider": "unknown",
|
|
||||||
"sessionCount": 7,
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": 0,
|
|
||||||
"cacheWrite": 0,
|
|
||||||
"input": 6,
|
|
||||||
"output": 0,
|
|
||||||
"total": 6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"totals": {
|
|
||||||
"cost": 287.25278186799954,
|
|
||||||
"tokens": 502055943
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default defineConfig({
|
||||||
vue(),
|
vue(),
|
||||||
copySqliteWasm(),
|
copySqliteWasm(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'prompt',
|
||||||
manifest: false,
|
manifest: false,
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm,woff,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm,woff,woff2}'],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
title: "IA and I"
|
title: 'IA and I'
|
||||||
date: 2026-06-19
|
date: 2026-06-19
|
||||||
description: "How I use IA"
|
description: 'How I use IA'
|
||||||
draft: true
|
draft: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ description: "Comment j'ai utilisé l'IA"
|
||||||
draft: true
|
draft: true
|
||||||
---
|
---
|
||||||
|
|
||||||
J'ai *massivement* utilisé l'IA pour créer TrainUs.
|
J'ai _massivement_ utilisé l'IA pour créer TrainUs.
|
||||||
|
|
||||||
## Pourquoi utiliser l'IA?
|
## Pourquoi utiliser l'IA?
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ Bref généralement je n'ai pas envie d'écrire du code, j'ai envie de créer qu
|
||||||
|
|
||||||
Du coup l'IA permet de résoudre cet équation.
|
Du coup l'IA permet de résoudre cet équation.
|
||||||
|
|
||||||
Pour **le temps**, elle peut travailler seule pendant que je fais autre chose (comme ma séance de sport). Attention ! Ce n'est pas toujours *gratuit* non plus, elle représente une **charge mentale** dans votre cerveau (en tout cas par moment pour moi car je lui tiens encore un peu la main). En plus elle est super efficace pour s'occuper des 20% restant ! Exemple: son expertise en CSS lui permet de résoudre très rapidement des petits glitch de GUI qui vous aurez pris pas mal de temps.
|
Pour **le temps**, elle peut travailler seule pendant que je fais autre chose (comme ma séance de sport). Attention ! Ce n'est pas toujours _gratuit_ non plus, elle représente une **charge mentale** dans votre cerveau (en tout cas par moment pour moi car je lui tiens encore un peu la main). En plus elle est super efficace pour s'occuper des 20% restant ! Exemple: son expertise en CSS lui permet de résoudre très rapidement des petits glitch de GUI qui vous aurez pris pas mal de temps.
|
||||||
|
|
||||||
Pour **l'envie**, elle vous permet de raffiner vos idées et de les transformer en produit que vous pouvez tester. La création est toujours de votre côté.
|
Pour **l'envie**, elle vous permet de raffiner vos idées et de les transformer en produit que vous pouvez tester. La création est toujours de votre côté.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -736,6 +736,39 @@ The **bip, bip, BIP** sequence (r = 2, r = 1, r = 0/expire) is evenly spaced at
|
||||||
|
|
||||||
**Key files:** `src/views/SessionExecuteView.vue`, `src/composables/useSessionExecution.js`, `src/composables/useAudioCues.js`, `src/db/repositories/executionLogRepository.js`, `src/utils/youtube.js`
|
**Key files:** `src/views/SessionExecuteView.vue`, `src/composables/useSessionExecution.js`, `src/composables/useAudioCues.js`, `src/db/repositories/executionLogRepository.js`, `src/utils/youtube.js`
|
||||||
|
|
||||||
|
#### Headset / Media-Key Control
|
||||||
|
|
||||||
|
Hardware media keys (headphone buttons, OS media keys, lock-screen controls) are only routed by the browser to a page **actively playing audio through a media element** — the Web Audio beeps from §"Audio Cues" above do not qualify, since they don't use `<audio>`/`<video>`. `useMediaSession.js` works around this with a silent, looping `<audio>` element (a 592-byte base64-encoded WAV data URI, no asset file) started when execution begins; the user's ▶ tap to start the session is the user gesture that satisfies the browser's autoplay policy.
|
||||||
|
|
||||||
|
`useMediaSession()` returns:
|
||||||
|
|
||||||
|
- `isSupported` — `'mediaSession' in navigator`; every other method is a no-op when `false`.
|
||||||
|
- `start({ onPlayPause, onNext, onPrevious, onStop })` — creates (once) and plays the silent audio element, registers `navigator.mediaSession` action handlers for `play`, `pause`, `nexttrack`, `previoustrack`, `stop`.
|
||||||
|
- `updateMetadata(step, sessionTitle)` — sets `navigator.mediaSession.metadata = new MediaMetadata({ title, artist, album })`, so the lock screen / headset display shows the current exercise. `title` = exercise title, `artist` = combo title (falls back to the session title outside a combo), `album` = session title.
|
||||||
|
- `setPlaybackState(state)` — sets `navigator.mediaSession.playbackState`.
|
||||||
|
- `stop()` — unregisters all action handlers, pauses and rewinds the silent audio.
|
||||||
|
|
||||||
|
`SessionExecuteView.vue` wires it in:
|
||||||
|
|
||||||
|
- `start()` is called in `onMounted`, right after `init()` succeeds.
|
||||||
|
- A `watch(currentStep, …)` — the same watcher that resets the exercise-detail panel — calls `updateMetadata(step, sessionTitle)`, so metadata refreshes on every step change, including the very first step.
|
||||||
|
- `stop()` is called in `onUnmounted` and whenever `sessionDone` becomes `true`.
|
||||||
|
|
||||||
|
Action mapping deliberately reuses the on-screen handlers rather than inventing media-specific logic:
|
||||||
|
|
||||||
|
| Media action | Maps to | Notes |
|
||||||
|
| ---------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `play` / `pause` | `togglePause()` if a timer is active, then `setPlaybackState('paused'\|'playing')` | No-op if no timer is running (nothing to pause) |
|
||||||
|
| `nexttrack` | `onDone()` on a normal step; `onContinue()`/`onContinueAmrap()` on the final-round / AMRAP-time-up screens | Mirrors whichever button is visible |
|
||||||
|
| `previoustrack` | `goBack()` (new in `useSessionExecution.js`) | No-op at the first step |
|
||||||
|
| `stop` | `onExit()` directly, no confirmation | A headset button can't answer a JS `confirm()` dialog |
|
||||||
|
|
||||||
|
**`goBack()`** decrements `currentIndex`, clears any final-round/AMRAP-time-up/rest screen state, stops the running timer, and calls the existing `loadStepState()` — so a timed step restarts its timer from the top of its slot/phase, consistent with how every other step transition works. It intentionally does not rewrite execution history: a step's previously logged item (if any) is left in place, and completing the step again after going back simply appends a new log item. A matching on-screen **Previous** button (`bi-skip-start-fill`, `data-testid="execute-previous-btn"`, disabled on the first step) sits next to **Done**, calling `goBack()` directly — so the feature doesn't require a headset to use or test.
|
||||||
|
|
||||||
|
**Testing:** `navigator.mediaSession` is replaced by a mock in `page.add_init_script` that records `setActionHandler` registrations and `metadata`/`playbackState` assignments, then invokes the captured handlers directly to simulate a headset button press. A dedicated red-path test deletes `Navigator.prototype.mediaSession` to confirm execution still works with the API entirely absent.
|
||||||
|
|
||||||
|
**Key files:** `src/composables/useMediaSession.js`, `src/composables/useSessionExecution.js` (`goBack()`), `src/views/SessionExecuteView.vue`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.8 Statistics
|
### 3.8 Statistics
|
||||||
|
|
@ -961,8 +994,8 @@ The optional `lang` parameter allows Vue computed properties to pass the reactiv
|
||||||
#### Service worker
|
#### Service worker
|
||||||
|
|
||||||
| Config | Value | Rationale |
|
| Config | Value | Rationale |
|
||||||
| ------------------------------- | ------------ | -------------------------------------------- |
|
| ------------------------------- | -------- | ----------------------------------------- |
|
||||||
| `registerType` | `autoUpdate` | Silently activates new SW on next navigation |
|
| `registerType` | `prompt` | New SW waits for user consent — see §3.14 |
|
||||||
| `maximumFileSizeToCacheInBytes` | 10 MB | Covers `sqlite3.wasm` (~8 MB) |
|
| `maximumFileSizeToCacheInBytes` | 10 MB | Covers `sqlite3.wasm` (~8 MB) |
|
||||||
| `manifest` | `false` | Hand-authored `public/manifest.json` kept |
|
| `manifest` | `false` | Hand-authored `public/manifest.json` kept |
|
||||||
|
|
||||||
|
|
@ -1018,6 +1051,31 @@ The `opfs-sahpool` VFS used by the DB worker (`src/db/worker.js`, see §2) only
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 3.14 App Update Prompt
|
||||||
|
|
||||||
|
The service worker was originally registered with `registerType: 'autoUpdate'`: when a new version was deployed, the new SW downloaded the full precache (including the ~8 MB `sqlite3.wasm`), took control, and reloaded the page — the user experienced an unexplained freeze. (The migration screen from §2 only covers database schema changes, not app-code updates.)
|
||||||
|
|
||||||
|
`registerType` is now `'prompt'`: the new SW still installs (precaches) in the background, but waits. Activation becomes a user decision, surfaced through a modal.
|
||||||
|
|
||||||
|
**Composable:** `useAppUpdate.js` — a module-level singleton wrapping `useRegisterSW` from `virtual:pwa-register/vue` (importing the virtual module also replaces the plugin's auto-injected registration script). It exposes:
|
||||||
|
|
||||||
|
- `needRefresh` — ref from `useRegisterSW`, true when a new SW is installed and waiting
|
||||||
|
- `applyUpdate()` — sets `updating`, then calls `updateServiceWorker(true)`; the page reloads when the new SW takes control, so `updating` is never reset
|
||||||
|
- `dismiss()` — session-only dismissal (plain ref, same pattern as `InstallPrompt`); the modal reappears on the next app load if the update is still pending
|
||||||
|
|
||||||
|
**Component:** `AppUpdateDialog.vue`, mounted once in `App.vue`'s DB-ready branch. Two states:
|
||||||
|
|
||||||
|
1. **Choice** — title, short body, **Update now** / **Later** buttons.
|
||||||
|
2. **Updating** — spinner + "Updating…" text, not dismissible: the `ModalDialog` gets no title (which removes its header close button) and the `close` event is ignored while `updating` is true (swallows Escape and backdrop clicks).
|
||||||
|
|
||||||
|
The modal is suppressed while the current route is the execution view (`route.name === 'session-execute'`) so an update prompt never interrupts a workout; it shows automatically once the user navigates away.
|
||||||
|
|
||||||
|
**Testing:** simulating a real SW update in Playwright would require two production builds, so `useAppUpdate.js` exposes a dev-only hook (`window.__appUpdateTest`, guarded by `import.meta.env.DEV`) that lets tests force `needRefresh` and mock the update call. The real update flow (deploy old build → deploy new build → prompt → update → reload) is validated manually.
|
||||||
|
|
||||||
|
**Key files:** `vite.config.js`, `src/composables/useAppUpdate.js`, `src/components/AppUpdateDialog.vue`, `src/App.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 4. Routing
|
## 4. Routing
|
||||||
|
|
||||||
All routes use hash mode (`createWebHashHistory`). The root path `/` redirects to `/home`.
|
All routes use hash mode (`createWebHashHistory`). The root path `/` redirects to `/home`.
|
||||||
|
|
|
||||||
|
|
@ -494,6 +494,21 @@ When you re-run a session, the reps and weight fields are prefilled with your va
|
||||||
|
|
||||||
Press the **✕** button at any time to exit. Steps you already completed are saved to history; unfinished steps are omitted. The session appears in history as a partial run.
|
Press the **✕** button at any time to exit. Steps you already completed are saved to history; unfinished steps are omitted. The session appears in history as a partial run.
|
||||||
|
|
||||||
|
### Headset / Media-Key Control
|
||||||
|
|
||||||
|
If your device and browser support it, you can control a running session with headphone buttons, OS media keys, or lock-screen controls — useful when your phone is locked away in a pocket or armband:
|
||||||
|
|
||||||
|
| Control | Effect |
|
||||||
|
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||||
|
| Play / Pause | Pauses or resumes the active timer (EMOM, AMRAP, TABATA) |
|
||||||
|
| Next | Same as tapping **✓ Done** (or **Continue**, on a round/time-up screen) |
|
||||||
|
| Previous | Goes back to the previous step — also available on-screen as a **◀** button next to Done |
|
||||||
|
| Stop | Exits immediately, no confirmation (steps already done stay saved) |
|
||||||
|
|
||||||
|
Going back to a previous step and completing it again logs it a second time; nothing already saved is removed. The lock screen / headset display shows the exercise you are currently on.
|
||||||
|
|
||||||
|
This feature depends on your browser and OS supporting the Media Session API — where it isn't supported, the app works exactly as before, just without headset control.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Statistics
|
## Statistics
|
||||||
|
|
@ -621,7 +636,9 @@ TrainUs shows a warning button (⚠) in the action bar when your last backup is
|
||||||
|
|
||||||
### App Updates
|
### App Updates
|
||||||
|
|
||||||
App updates are automatic and preserve your data — you never need to export and re-import anything when a new version of TrainUs is released.
|
App updates preserve your data — you never need to export and re-import anything when a new version of TrainUs is released.
|
||||||
|
|
||||||
|
When a new version is available, TrainUs shows an **Update available** dialog. Choose **Update now** to apply it immediately — a short "Updating…" spinner is shown while the new version takes over and the app reloads — or **Later** to keep working; the dialog comes back the next time you open the app. The dialog never interrupts a workout: if you are executing a session, it waits until you leave the execution screen.
|
||||||
|
|
||||||
If an update changes the database structure, TrainUs shows a one-time update screen at startup. As a safety measure, it asks you to **download a backup** of your data first; the **Update** button becomes available once the backup has been saved. The update then runs in a few seconds and the app continues with all your data in place. If anything were to go wrong, your data is left unchanged and you hold the backup you just downloaded.
|
If an update changes the database structure, TrainUs shows a one-time update screen at startup. As a safety measure, it asks you to **download a backup** of your data first; the **Update** button becomes available once the backup has been saved. The update then runs in a few seconds and the app continues with all your data in place. If anything were to go wrong, your data is left unchanged and you hold the backup you just downloaded.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,21 @@ Quand vous relancez une séance, les champs répétitions et poids sont pré-rem
|
||||||
|
|
||||||
Le bouton **✕** permet de quitter à tout moment. Les étapes déjà terminées sont enregistrées dans l'historique ; les étapes non terminées sont omises. La séance apparaît dans l'historique comme une exécution partielle.
|
Le bouton **✕** permet de quitter à tout moment. Les étapes déjà terminées sont enregistrées dans l'historique ; les étapes non terminées sont omises. La séance apparaît dans l'historique comme une exécution partielle.
|
||||||
|
|
||||||
|
### Contrôle par écouteurs / touches multimédia
|
||||||
|
|
||||||
|
Si votre appareil et votre navigateur le permettent, vous pouvez contrôler une séance en cours avec les boutons de vos écouteurs, les touches multimédia du système, ou les commandes de l'écran verrouillé — pratique quand votre téléphone reste rangé dans une poche ou un brassard :
|
||||||
|
|
||||||
|
| Commande | Effet |
|
||||||
|
| --------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| Lecture / Pause | Met en pause ou reprend le chronomètre actif (EMOM, AMRAP, TABATA) |
|
||||||
|
| Suivant | Équivaut à cliquer sur **✓ Terminé** (ou **Continuer**, sur un écran de fin de tour/temps écoulé) |
|
||||||
|
| Précédent | Revient à l'étape précédente — également disponible à l'écran via un bouton **◀** à côté de Terminé |
|
||||||
|
| Stop | Quitte immédiatement, sans confirmation (les étapes déjà faites restent enregistrées) |
|
||||||
|
|
||||||
|
Revenir à une étape précédente puis la terminer à nouveau l'enregistre une seconde fois ; rien de ce qui a déjà été sauvegardé n'est supprimé. L'écran verrouillé / l'affichage des écouteurs montre l'exercice en cours.
|
||||||
|
|
||||||
|
Cette fonctionnalité dépend de la prise en charge de la Media Session API par votre navigateur et votre système — là où elle n'est pas prise en charge, l'application fonctionne exactement comme avant, simplement sans contrôle par écouteurs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Statistiques
|
## Statistiques
|
||||||
|
|
@ -623,7 +638,9 @@ TrainUs affiche un bouton d'alerte (⚠) dans la barre d'action quand votre dern
|
||||||
|
|
||||||
### Mises à jour de l'application
|
### Mises à jour de l'application
|
||||||
|
|
||||||
Les mises à jour de l'application sont automatiques et préservent vos données — vous n'avez jamais besoin d'exporter puis ré-importer quoi que ce soit lors de la sortie d'une nouvelle version de TrainUs.
|
Les mises à jour de l'application préservent vos données — vous n'avez jamais besoin d'exporter puis ré-importer quoi que ce soit lors de la sortie d'une nouvelle version de TrainUs.
|
||||||
|
|
||||||
|
Quand une nouvelle version est disponible, TrainUs affiche une boîte de dialogue **Mise à jour disponible**. Choisissez **Mettre à jour** pour l'appliquer immédiatement — un court indicateur « Mise à jour en cours… » s'affiche pendant que la nouvelle version prend le relais et que l'application se recharge — ou **Plus tard** pour continuer à travailler ; la boîte de dialogue reviendra à la prochaine ouverture de l'application. Elle n'interrompt jamais un entraînement : si vous êtes en train d'exécuter une séance, elle attend que vous quittiez l'écran d'exécution.
|
||||||
|
|
||||||
Si une mise à jour change la structure de la base de données, TrainUs affiche un écran de mise à jour unique au démarrage. Par sécurité, il vous demande d'abord de **télécharger une sauvegarde** de vos données ; le bouton **Mettre à jour** devient disponible une fois la sauvegarde enregistrée. La mise à jour s'exécute alors en quelques secondes et l'application continue avec toutes vos données en place. Si quoi que ce soit devait mal se passer, vos données restent inchangées et vous disposez de la sauvegarde que vous venez de télécharger.
|
Si une mise à jour change la structure de la base de données, TrainUs affiche un écran de mise à jour unique au démarrage. Par sécurité, il vous demande d'abord de **télécharger une sauvegarde** de vos données ; le bouton **Mettre à jour** devient disponible une fois la sauvegarde enregistrée. La mise à jour s'exécute alors en quelques secondes et l'application continue avec toutes vos données en place. Si quoi que ce soit devait mal se passer, vos données restent inchangées et vous disposez de la sauvegarde que vous venez de télécharger.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue