Cette page décrit chaque partie de Kaivalya Trainer en détail : pile technique, schéma, routes, composables. Pour une première prise en main guidée, voir Démarrage rapide ; pour des tâches concrètes, voir Guides pratiques ; pour le « pourquoi » des choix, voir Comprendre l’architecture.
Paternité IA. La grande majorité du code source de ce projet a été écrite par des assistants IA (principalement Claude). L’auteur humain a dirigé l’architecture, relu chaque modification, guidé l’implémentation et pris toutes les décisions de conception et de produit — mais si l’auteur, c’est celui qui tape les lignes, ce crédit revient en grande partie à l’IA. Cela est indiqué ici par souci de transparence, et non comme une mise en garde : le code a été relu et validé à chaque étape.
1. Architecture
Kaivalya Trainer est une Progressive Web Application (PWA) qui tourne entièrement dans le navigateur — il n’y a pas de serveur backend, et toutes les données sont stockées localement via SQLite WASM avec une persistance OPFS (Origin Private File System). L’application fonctionne hors ligne après le premier chargement et peut être installée sur l’écran d’accueil. Voir Comprendre l’architecture pour le pourquoi de ces choix.
Pile technique
| Couche | Technologie |
|---|---|
| Framework UI | Vue 3 (Composition API) |
| Build | Vite |
| Routage | Vue Router (mode hash) |
| i18n | i18next + i18next-vue |
| Base de données | SQLite WASM + OPFS |
| Graphiques | Chart.js 4 + vue-chartjs 5 |
| Cartes | Leaflet 1.9 (OSM / IGN) |
| Icônes | Bootstrap Icons |
| Markdown | marked + DOMPurify |
| PWA | vite-plugin-pwa + Workbox |
| Formatage | Prettier |
| Tests | Playwright (Python) |
| Licence | GNU AGPL v3 |
Structure du projet
kaivalya-trainer/
├── index.html # Point d'entrée
├── package.json # Dépendances & scripts npm
├── vite.config.js # Configuration Vite + PWA + CORS
├── public/
│ ├── manifest.json # Manifeste PWA (écrit à la main)
│ ├── icon-192.png # Icône PWA 192×192
│ ├── icon-512.png # Icône PWA 512×512
│ └── sqlite3-opfs-async-proxy.js # Proxy OPFS async pour le worker SQLite
├── src/
│ ├── main.js # Démarrage de l'app (init DB, restauration des réglages)
│ ├── App.vue # Composant racine (layout général, navigation)
│ ├── router/
│ │ └── index.js # Vue Router — les 21 routes
│ ├── views/ # Composants de page (un par route)
│ ├── components/ # Composants UI réutilisables
│ ├── composables/ # Composables Vue (données réactives & logique)
│ ├── db/
│ │ ├── database.js # Gestionnaire de DB (pont vers le worker)
│ │ ├── worker.js # Web Worker (SQLite WASM + OPFS)
│ │ ├── schema.js # DDL + SCHEMA_VERSION + MIGRATIONS
│ │ ├── releases.js # Registre des releases pour la compatibilité de restauration
│ │ └── repositories/ # Couche d'accès aux données, par entité
│ ├── i18n/
│ │ ├── index.js # Initialisation i18next
│ │ └── locales/
│ │ ├── en.json # Traductions anglaises
│ │ └── fr.json # Traductions françaises
│ ├── styles/
│ │ ├── variables.css # Variables CSS personnalisées (thèmes clair/sombre)
│ │ └── layout.css # Mise en page globale, navigation, barre d'action
│ └── utils/ # Fonctions utilitaires pures (validation, dateFormatter, markdown, …)
├── docs/ # Toute la documentation
└── tests/ # Suite de tests Playwright en Python
Statistiques du code
Lignes de code par zone, mesurées avec cloc (fichiers générés, dépendances et thème Hugo exclus) :
| Zone | Fichiers | Lignes de code | Langages principaux |
|---|---|---|---|
src/ (application) | 81 | 13 973 | Vue SFC, JavaScript, CSS |
tests/ (Playwright) | 37 | 7 371 | Python |
website/ (site de docs) | 49 | 4 732 | Markdown, CSS, HTML |
docs/ | 4 | 1 500 | Markdown |
Dans src/, les composants Vue monofichiers constituent l’essentiel du code applicatif (9 045 lignes réparties sur 32 composants), avec 4 065 lignes de JavaScript pur (composables, couche DB, utilitaires, router).
Architecture thread principal / Worker
SQLite tourne dans un Web Worker pour accéder aux API OPFS, indisponibles sur le thread principal.
Thread principal Thread Worker
┌──────────────────────┐ ┌──────────────────┐
│ database.js │──postMsg()──▶│ worker.js │
│ exec / run / │◀──postMsg()──│ sqlite3 WASM │
│ selectAll / │ │ stockage OPFS │
│ exportDatabase / │ └──────────────────┘
│ importDatabase │
├──────────────────────┤
│ repositories/ │ CRUD asynchrone pur, par entité
├──────────────────────┤
│ composables/ │ Wrappers réactifs pour Vue
└──────────────────────┘
La communication est entièrement basée sur des Promises : database.js enveloppe chaque postMessage() dans une Promise résolue par un ID de réponse correspondant.
En-têtes COOP / COEP
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Voir Guides pratiques → Déployer Kaivalya Trainer pour la configuration côté serveur, et Comprendre l’architecture pour savoir pourquoi le VFS utilisé ici n’en a pas strictement besoin.
Design responsive
| Seuil | Mise en page |
|---|---|
| > 700 px | Menu latéral fixe (liens avec libellés) |
| ≤ 700 px | Barre de navigation en bas (icônes + libellés) |
| ≤ 400 px | Navigation en bas, icônes seules |
Thèmes
Les thèmes clair et sombre sont implémentés via des variables CSS personnalisées sur :root / [data-theme="dark"]. Le thème est conservé dans la table settings et appliqué avant le premier rendu (pas de flash).
2. Couche base de données
VFS : OPFS SyncAccessHandle Pool
L’application utilise le VFS opfs-sahpool :
- E/S synchrones via
FileSystemSyncAccessHandle - Fonctionne sur tous les navigateurs majeurs depuis mars 2023 (Firefox 111+, Chrome 102+, Safari 16.4+)
- Ne nécessite pas les en-têtes COOP/COEP (même si nous les avons)
- Connexion unique seulement (suffisant pour une PWA mono-utilisateur)
Si OPFS n’est pas disponible (navigation privée, navigateur ancien), l’application affiche une erreur en plein écran. Il n’y a pas de repli en mémoire. Voir Comprendre l’architecture pour savoir pourquoi ce VFS a été choisi et pourquoi il n’y a pas de repli.
Schéma
12 tables, toutes définies avec CREATE TABLE IF NOT EXISTS (idempotent). Le SCHEMA_VERSION actuel est 2.
| Table | Rôle |
|---|---|
schema_version | Suivi des migrations (version + applied_at) |
settings | Configuration clé-valeur (user_uuid, nom, thème, …) |
exercise | Définitions d’exercices (title UNIQUE) ; catégorie (force/cardio) + cible |
combo | Définitions de combos (title UNIQUE) |
combo_exercise | Jonction combo ↔ exercice (position, reps, poids, durée cardio) |
session | Définitions de séances (title UNIQUE) ; option libre (free-style) |
session_item | Jonction séance ↔ élément (type, position, reps, poids, cardio) |
collection | Collections (label UNIQUE) |
collection_session | Jonction collection ↔ séance (position) |
execution_log | Journaux d’exécution (started_at, finished_at) |
execution_log_item | Résultats d’exécution par exercice (reps, poids, côté, cardio/GPS…) |
suffix | Registre des utilisateurs externes, pour désambiguïser l’import |
Diagramme entité-relation
Détails de colonnes notables :
session_item.item_type:"exercise"ou"combo"— utilisé pour faire un JOIN avec la bonne table pour récupérer les titresexecution_log_item.combo_id/round_number: NULL pour les exercices autonomes ; renseignés pour les exercices exécutés au sein d’un combo (pas de contrainte FK — le combo peut être supprimé après exécution)exercise.category:'strength'(défaut) ou'cardio';target_unit('time'/'distance') ainsi quedefault_duration_sec/default_distance_m(0= pas de cible) ne s’appliquent qu’aux exercices cardio.session_item.duration_sec/distance_metcombo_exercise.duration_secsont des surcharges par utilisation (NULL= hérite de la valeur par défaut de l’exercice) ;combo_exercise.duration_secne s’applique qu’aux éléments cardio des combos de typeNONE(temps uniquement, avance automatique).execution_log_item.duration_sec/distance_m/elevation_gain_m/elevation_loss_m/gps_track: résultats cardio d’une étape (gps_trackest un blob JSON{points, source, elevationSource}, une ligne par étape) ; tousNULLpour les éléments de force.- Clés de
settings:user_uuid,user_name,app_version,language,theme,db_created_at,last_backup_at,backup_reminder_days
Contraintes UNIQUE
L’unicité des titres/libellés est imposée au niveau de la base sur exercise.title, combo.title, session.title, collection.label. Les violations remontent comme des erreurs UNIQUE constraint failed, gérées par les repositories.
Table suffix
Stocke la correspondance entre les UUID d’utilisateurs externes et les suffixes qui leur sont attribués. Utilisée pendant l’import pour désambiguïser les titres provenant d’autres utilisateurs (voir §3.9).
Système de migration
Au démarrage, database.js envoie un message getSchemaStatus au worker, qui détecte l’état de la base sans modifier les données existantes :
- Base neuve (pas de ligne dans
schema_version) : exécuteSCHEMA_SQLet estampilleSCHEMA_VERSION. - À jour (
dbVersion === SCHEMA_VERSION) : continue normalement. - Plus récente que l’application (
dbVersion > SCHEMA_VERSION) : erreur bloquante (DB_NEWER_THAN_APP), données intactes. - Plus ancienne que l’application (
dbVersion < SCHEMA_VERSION) : l’application s’arrête sur un écran de migration bloquant. L’utilisateur doit d’abord télécharger une sauvegarde ; ce n’est qu’alors que le bouton Mettre à jour s’active, ce qui envoierunMigrationsau worker.
runMigrations applique les scripts de migration en attente, fournis par getMigrationScripts(dbVersion, SCHEMA_VERSION) dans src/db/releases.js. Le script de chaque release s’exécute dans une transaction avec son INSERT INTO schema_version (version) VALUES (N) ; en cas d’erreur, la transaction est annulée, la base reste à la version précédente, et une erreur bloquante DB_MIGRATION_FAILED est affichée (l’utilisateur dispose de la sauvegarde téléchargée juste avant).
Les scripts de migration sont appliqués instruction par instruction par _runMigrationScript() (src/db/worker.js), qui est idempotente pour ALTER TABLE ... ADD COLUMN : avant d’exécuter une telle instruction, elle vérifie PRAGMA table_info(table) et l’ignore si la colonne existe déjà. Ainsi, rejouer une migration sur une cible déjà au schéma de destination (ou partiellement migrée) est un no-op plutôt qu’un échec « duplicate column name » — utilisé à la fois par runMigrations (démarrage) et par l’étape d’adaptation de la restauration ci-dessous (fichier de sauvegarde importé).
Les changements de schéma en place (ajouter des colonnes directement dans SCHEMA_SQL) sont acceptables avant toute publication auprès des utilisateurs. Une augmentation de SCHEMA_VERSION est réservée aux changements de schéma faits après qu’une version a été déployée — à ce moment, la release embarque un migrationScript dans RELEASES (voir §6). Voir Guides pratiques → Ajouter une migration de base de données pour la recette pas à pas.
Pattern Repository
Chaque entité a un repository dédié dans src/db/repositories/ fournissant les méthodes standards : create, getById, getAll, update, delete, search. La gestion des tables de jonction est intégrée au repository parent (par exemple setExercises() dans comboRepository).
Les repositories n’utilisent que database.exec(), database.run() et database.selectAll() — aucune construction spécifique à SQLite hors de cette couche.
Composables Vue
Les wrappers réactifs dans src/composables/ exposent un état basé sur des ref (items, loading, error) et des méthodes asynchrones, à destination des composants Vue. Les composants n’importent jamais les repositories directement ; ils passent par les composables.
| Composable | Enveloppe |
|---|---|
useExercises | exerciseRepository |
useCombos | comboRepository |
useSessions | sessionRepository |
useCollections | collectionRepository |
useExecutionLogs | executionLogRepository |
useSettings | settingsRepository |
useStats | Plusieurs repositories (agrégats en lecture seule) |
useSessionExecution | executionLogRepository + logique de file |
useJsonExport | Tous les repositories + jsonEnvelope.js |
useJsonImport | Tous les repositories + suffixRepository |
useBackupStatus | settingsRepository |
useInstallPrompt | Événement beforeinstallprompt (API navigateur) |
useHtmlExport | Rendu de données pur — aucun appel à un repository |
3. Fonctionnalités
3.1 Réglages
Vue : src/views/SettingsView.vue — #/settings
Restauration au démarrage : au lancement de l’application (src/main.js), après l’initialisation de la base et avant le premier rendu, language et theme sont lus depuis settings et appliqués. Cela évite tout flash de valeurs par défaut.
Réglages disponibles
| Réglage | Contrôle | Clé de persistance |
|---|---|---|
| Langue | <select> (English / Français / Dansk) | settings.language |
| Nom d’utilisateur | Champ texte (requis, non vide) | settings.user_name |
| Identifiant utilisateur | Affichage UUID en lecture seule | settings.user_uuid |
| Thème | <select> (Clair / Sombre) | settings.theme |
| Volume sonore | Curseur (0–100 %) + bouton Tester | settings.audio_volume |
| Télécharger la DB | Bouton → télécharge un .sqlite3 | — |
| Restaurer la DB | Sélecteur de fichier → pipeline de validation | — |
| Initialiser la DB | Bouton + case à cocher obligatoire | — |
| Intervalle de rappel de sauvegarde | Champ numérique (1–365, défaut 7) | settings.backup_reminder_days |
| Exporter des données | Bouton → boîte de filtre par entité | — |
| Importer des données | Bouton → sélecteur de fichier | — |
| Gestion des suffixes | Liste des suffixes enregistrés | table suffix |
| Crédits | Bouton → fenêtre modale (attributions icônes/bibliothèques) | — |
| À propos | Bouton → fenêtre modale (version, auteur) | — |
Le volume sonore est détaillé en § 3.7 Signaux sonores.
Télécharger la base de données
Appelle sqlite3_js_db_export() dans le worker. Le fichier SQLite brut est téléchargé sous le nom kaivalya-trainer-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3 et last_backup_at est mis à jour en cas de succès.
Restaurer la base de données
Pipeline en plusieurs étapes, exécuté dans le worker :
La copie table par table ne transfère que les colonnes présentes dans les deux schémas. L’opération entière s’exécute dans une transaction ; en cas d’échec, elle est annulée. Après succès, les réglages (langue, thème) sont réappliqués sur place, sans rechargement de page.
La compatibilité des versions est gérée par src/db/releases.js : un tableau RELEASES où chaque entrée déclare schemaVersion, appVersion, dbBreak, et un migrationScript optionnel. checkCompatibility() parcourt cet historique pour déterminer si un chemin de migration existe. Les mêmes scripts migrationScript pilotent à la fois l’adaptation de restauration et la migration au démarrage (§ Système de migration).
Initialiser la base de données
Supprime toutes les données de toutes les tables, dans l’ordre sûr pour les clés étrangères (enfants en premier), puis appelle _initDefaults() pour régénérer user_uuid, user_name, app_version, db_created_at. La page se recharge pour refléter le nouvel état. Une case de confirmation obligatoire empêche tout usage accidentel.
Fichiers clés :
src/views/SettingsView.vue, src/db/database.js, src/db/worker.js, src/db/releases.js, src/db/repositories/settingsRepository.js
Fenêtre « Crédits »
Une fenêtre modale ouverte depuis la carte Crédits de la page Réglages, attribuant les ressources tierces, les données cartographiques et les bibliothèques.
Bibliothèques, sources cartographiques et outils de test sont trois tableaux de { name, url, license } dans le composant, rendus chacun par un v-for ; seules les icônes sont disposées à la main. Auparavant la liste était une suite d’ancres écrites à la main, sans aucune licence : c’est ainsi que Leaflet — BSD-2-Clause, et ce qui dessine toutes les cartes de l’application — s’est retrouvé nommé sans être crédité.
| Groupe | Contenu |
|---|---|
| Icône de l’app (Mighty Force) | CC BY 3.0 — game-icons.net, par Delapouite |
| Bootstrap Icons | MIT — icons.getbootstrap.com |
| Cartes et données d’altitude | OpenStreetMap (ODbL, © OpenStreetMap contributors), IGN Géoplateforme (© IGN-F/Géoportail), Open Topo Data (SRTM — NASA / USGS) |
| Bibliothèques tierces | 13 entrées, chacune avec l’identifiant SPDX déclaré par son propre manifeste |
| Outils de test | Playwright (Apache-2.0), pytest (MIT) |
Deux licences se retiennent mal et sont donc lues sur le paquet plutôt que de mémoire : SQLite WASM est en Apache-2.0 (le domaine public, c’est SQLite lui-même, pas l’enveloppe npm), et DOMPurify est en double licence MPL-2.0 / Apache-2.0.
Les fournisseurs de cartes reçoivent une chaîne d’attribution plutôt qu’un identifiant SPDX — c’est ce qu’ils demandent, et inventer un identifiant pour les conditions de la Géoplateforme IGN serait une affirmation, pas un crédit. L’attributionControl de Leaflet les imprime déjà toutes les trois sur la carte, mais uniquement tant qu’une carte est à l’écran ; la fenêtre est l’endroit où elles restent lisibles à tout moment. La même liste est publiée sur le site du projet, à /credits/, avec un lien depuis le pied de page.
Les textes descriptifs proviennent de l’espace de noms i18n credits.* ; les noms, URL et identifiants de licence ne sont pas traduits.
Composant : src/components/CreditsDialog.vue
Fenêtre « À propos »
Une fenêtre modale ouverte depuis la carte À propos de Kaivalya Trainer en bas de la page Réglages. Elle affiche :
| Champ | Source |
|---|---|
| Version de l’application | __APP_VERSION__ (variable globale Vite issue de package.json) |
| Version de la base de données | SCHEMA_VERSION (depuis src/db/schema.js) |
| Créé par | Texte fixe : David Florance |
| Dépôt | Lien provisoire (href="#") |
| Site web | Lien provisoire (href="#") |
| Voir la licence | Bouton désactivé, avec la note « Licence à déterminer. » |
Composant : src/components/AboutDialog.vue
Les clés i18n sont sous l’espace de noms about.* dans tous les fichiers de locale.
3.2 Gestion des exercices
Vues : ExerciseListView, ExerciseFormView, ExerciseDetailView
Routes : #/exercises, #/exercises/new, #/exercises/:id, #/exercises/:id/edit
Champs du formulaire
| Champ | Validation |
|---|---|
| Titre | Requis, UNIQUE (au niveau base) |
| Description | Optionnelle ; rendue en Markdown dans la vue détail (marked + DOMPurify) |
| Catégorie | strength (défaut) ou cardio — change le reste du formulaire |
| Asymétrique | Booléen ; révèle la sous-option Alternance. Force uniquement : masqué et forcé à false si la catégorie est cardio |
| Alternance | Bouton radio : alterner par répétition ou par série |
| Répétitions par défaut | ≥ 0 (force uniquement) |
| Poids par défaut | ≥ 0 (force uniquement) |
| Cible | Cardio uniquement : bouton radio (temps/distance) + une valeur, ≥ 0 (0 = pas de cible) |
| URLs d’images | Chaque URL doit être une URL HTTP(S) valide |
| URLs de vidéos | Chaque URL doit être une URL HTTP(S) valide |
ExerciseFormView est partagée entre les modes création et édition, distingués via route.name (exercise-new vs exercise-edit).
Catégorie et cible (cardio)
Chaque exercice a une category : strength (défaut, comportement reps × poids d’aujourd’hui, inchangé) ou cardio. Un exercice cardio a une target_unit — time (minutes dans le formulaire, stockées en default_duration_sec) ou distance (km dans le formulaire, stockées en default_distance_m) — 0 signifiant pas de cible. Choisir cardio masque les champs reps/poids et affiche la section Cible à la place ; à l’enregistrement, les champs de la catégorie non sélectionnée sont forcés à leur valeur zéro/défaut pour qu’un changement de catégorie ne laisse jamais de données obsolètes. Les vues liste et détail affichent un badge bi-heart-pulse pour les exercices cardio, ainsi que la cible formatée (src/utils/cardioFormat.js) sur la vue détail. session_item/combo_exercise peuvent surcharger la cible par utilisation (voir § 3.4/3.3) ; ce câblage arrive à partir de l’étape 31b.
Barre d’action (pattern Teleport)
Les vues injectent des boutons dans la barre d’action partagée via <Teleport to="#actionbar-actions" defer>. L’attribut defer (Vue 3.5+) résout la cible au cycle de rendu suivant, ce qui évite une condition de course avec le shell conditionné par v-else.
Utilitaire de validation
src/utils/validation.js fournit des validateurs réutilisables, qui renvoient des clés i18n :
validateRequired(value)→'validation.required'validateMin(value, min)→'validation.minValue'validateUrl(value)→'validation.invalidUrl'
Recherche
Barre de recherche activable, avec une temporisation de 250ms → exerciseRepository.search() (SQL LIKE %query%). Un bouton d’effacement (×) s’affiche dans le champ dès qu’il contient une valeur ; il vide le champ et lui redonne le focus. Vider le champ (via ce bouton, manuellement, ou en désactivant la recherche) appelle fetchAll(). Le même schéma (barre de recherche, temporisation, bouton d’effacement) est dupliqué dans ExerciseListView, ComboListView, SessionListView et CollectionsView plutôt que factorisé dans un composable partagé.
Intégration des médias
- Images : affichées dans une grille responsive, avec chargement différé. Un gestionnaire
@errormasque les images cassées. - YouTube : les ID de vidéo sont extraits des formats
youtube.com/watch?v=etyoutu.be/, et intégrés viayoutube-nocookie.com. - Autres URLs : affichées comme des liens cliquables.
Fichiers clés : src/views/ExerciseListView.vue, src/views/ExerciseFormView.vue, src/views/ExerciseDetailView.vue, src/utils/validation.js, src/utils/markdown.js, src/utils/youtube.js
3.3 Gestion des combos
Vues : ComboListView, CombosView, ComboFormView, ComboDetailView
Routes : #/combos, #/combos/new, #/combos/:id, #/combos/:id/edit
Un combo est un groupe ordonné d’exercices avec un format minuté optionnel (EMOM, AMRAP, TABATA, ou NONE pour de simples supersets).
Champs du formulaire
| Champ | Notes |
|---|---|
| Titre | Requis, UNIQUE |
| Description | Optionnelle ; rendue en Markdown dans la vue détail |
| Type | NONE / EMOM / AMRAP / TABATA |
| Configuration du minuteur | Durée (min) pour EMOM/AMRAP ; nombre de tours pour TABATA ; stocké en JSON |
| Exercices | Liste ordonnée avec, par exercice, répétitions (≥ 1) et poids (≥ 0) |
Chaque exercice du combo a ses propres default_reps et default_weight, stockés dans la table de jonction combo_exercise. Ces valeurs surchargent les valeurs par défaut de l’exercice lorsque le combo est utilisé dans une séance.
Éléments cardio
Un exercice cardio dans un combo est uniquement temporel : il stocke combo_exercise.duration_sec et n’affiche aucun champ répétitions/poids. Le champ durée n’apparaît que pour les combos NONE, où chaque élément déroule son propre compte à rebours ; en EMOM / AMRAP / TABATA l’élément suit le minuteur du combo et le champ est remplacé par une indication. Prérempli depuis le default_duration_sec de l’exercice quand son unité d’objectif est le temps, minimum 5 s (validateMin(value, 5)).
C’est ce qui fait du jeffing un simple combo NONE d’éléments cardio (p. ex. courir 60 s / marcher 30 s) répété par les repetitions de l’élément de séance — sans type de combo dédié.
Ordonnancement des exercices
Les boutons monter/descendre ajustent la position. comboRepository.setExercises() remplace en une seule opération toute la table de jonction d’un combo (suppression + réinsertion).
Forme du retour de getExercises()
{
;(exerciseId, exerciseTitle, position, defaultReps, defaultWeight, asymmetric, alternate)
}
asymmetric et alternate sont inclus pour que le mode exécution puisse déterminer la gestion du côté sans requête supplémentaire.
Fichiers clés : src/views/ComboListView.vue, src/views/ComboFormView.vue, src/views/ComboDetailView.vue, src/db/repositories/comboRepository.js
3.4 Gestion des séances
Vues : SessionListView, SessionsView, SessionFormView, SessionDetailView, SessionExecuteView
Routes : #/sessions, #/sessions/new, #/sessions/:id, #/sessions/:id/edit, #/sessions/:id/execute
Une séance est une liste ordonnée d’éléments — chaque élément est soit un exercice, soit un combo, avec des répétitions et un poids propres à l’élément (ou, pour les exercices cardio, un objectif).
Éléments de séance
session_item utilise une paire polymorphe item_id + item_type :
item_type IN ('exercise', 'combo')
sessionRepository.getItems() exécute deux requêtes séparées (une avec JOIN sur exercise, une avec JOIN sur combo) puis fusionne les résultats triés par position. SQLite ne supporte pas les JOIN conditionnels entre différentes tables dans une seule requête.
Champs du formulaire
| Champ | Notes |
|---|---|
| Titre | Requis, UNIQUE |
| Description | Optionnelle ; rendue en Markdown dans la vue détail |
| Éléments | Liste ordonnée ; chaque élément a des répétitions (≥ 1) et un poids (≥ 0) |
| Style libre | Case à cocher (session.free_style) ; les objectifs sont ignorés à l’exécution |
Le sélecteur de type d’élément (exercice vs combo) contrôle quel menu déroulant est rempli. Les éléments peuvent être déplacés vers le haut/bas ou supprimés.
Éléments cardio et objectifs hérités
Un élément de séance pointant vers un exercice cardio remplace les compteurs répétitions/poids par un unique champ objectif, exprimé dans l’unité de l’exercice (minutes ou km — l’unité elle-même n’est pas surchargeable par élément).
Les colonnes de surcharge session_item.duration_sec / .distance_m restent à NULL tant que l’élément suit l’objectif de l’exercice. SessionFormView préremplit le champ avec la valeur par défaut de l’exercice et réécrit NULL dès que la valeur lui est toujours égale ; toute autre valeur est enregistrée comme surcharge. Augmenter plus tard l’objectif d’un exercice augmente donc tous les éléments qui ne l’ont jamais surchargé, tandis que les valeurs explicites sont préservées.
SessionDetailView affiche l’objectif effectif — duration_sec ?? default_duration_sec (resp. distance_m ?? default_distance_m) — formaté via src/utils/cardioFormat.js.
Sémantique des répétitions
Pour un élément de séance qui est un combo, repetitions = le nombre de passages complets dans la liste d’exercices du combo (pas les répétitions individuelles par exercice).
Fichiers clés : src/views/SessionListView.vue, src/views/SessionFormView.vue, src/views/SessionDetailView.vue, src/db/repositories/sessionRepository.js
3.5 Gestion des collections
Vues : CollectionsView, CollectionFormView, CollectionDetailView
Routes : #/collections, #/collections/new, #/collections/:id, #/collections/:id/edit
Une collection est un groupe ordonné de séances — utile pour organiser des programmes hebdomadaires ou des phases d’entraînement.
Champs du formulaire
| Champ | Notes |
|---|---|
| Libellé | Requis, UNIQUE |
| Séances | Liste ordonnée ; chaque séance ne peut apparaître qu’une fois |
collectionRepository.getAll() inclut un champ sessionCount, via une requête COUNT sur collection_session — ce qui évite de charger toutes les données de séance pour la vue liste.
Fichiers clés : src/views/CollectionsView.vue, src/views/CollectionFormView.vue, src/views/CollectionDetailView.vue, src/db/repositories/collectionRepository.js
3.6 Page d’accueil
Vue : HomeView — #/home
Page d’accueil avec un aperçu rapide de l’activité d’entraînement récente.
| Widget | Source de données |
|---|---|
| Séances des 7 derniers jours | executionLogRepository.getCountSince(isoDate) |
| Séances des 30 derniers jours | executionLogRepository.getCountSince(isoDate) |
| Les 4 séances les plus récentes | executionLogRepository.getRecentWithItems(4) (JOIN avec session et ses éléments) |
| Historique par jour (filtré) | executionLogRepository.getAllWithItems() — filtré côté client par plage de dates |
Séances récentes
Les 4 séances les plus récemment exécutées sont affichées sous forme de cartes. Chaque carte affiche le titre de la séance, la date d’exécution (formatée selon la locale via dateFormatter.js), la durée totale (quand finished_at est renseigné), un bouton lecture (▶) pour relancer la séance, et un résumé compact intégré de chaque exercice/combo réalisé, avec les répétitions, le poids et la durée par élément réellement effectués.
Le résumé intégré regroupe les éléments via les fonctions groupItems() / enrichItemsWithTiming() / comboRounds(), définies dans HomeView. Le minutage par élément est dérivé des écarts entre execution_log_item.timestamp consécutifs, relativement à execution_log.started_at.
Section historique
Tous les journaux d’exécution sont chargés une seule fois au montage via getAllWithItems(), puis filtrés côté client selon la plage de dates active (par défaut : les 30 derniers jours). Le résultat est regroupé par jour calendaire (du plus récent au plus ancien) via une computed Vue. Chaque groupe de jour a un en-tête et une carte compacte par séance, elle aussi avec durée, bouton lecture et résumé intégré des éléments.
Deux sélecteurs <input type="date"> (de / à) permettent d’ajuster la plage de façon interactive, sans aller-retour réseau.
Traces passées (cardio)
Les cartes récentes et les lignes d’historique portent une icône carte — uniquement si l’exécution contient des données cardio (logHasCardioData() dans src/utils/cardioSummary.js : un élément avec un gps_track, une distance_m ou une duration_sec). Elle ouvre TrackMapDialog.vue, également accessible depuis l’écran de fin de séance cardio.
La boîte de dialogue :
- trace chaque élément enregistré dans sa propre couleur, rappelée par une pastille dans la liste détaillée, et cadre la carte sur l’ensemble de la séance (couche choisie d’après le premier point, comme à l’exécution). Un élément est tracé en une polyligne par portion continue (
splitTrackSegments()), pour qu’une pause ou une page gelée laisse un trou plutôt qu’une ligne droite par-dessus, et une portion réduite à un seul point est dessinée comme un marqueur — la carte d’exécution démarre dès la toute première position, si bien qu’une course n’en ayant obtenu qu’une était enregistrée puis déclarée sans trace ici ; - totalise distance, temps, vitesse moyenne et D+/D− (
totals(); la vitesse estspeedKmh()— distance totale ÷ temps total, jamais une moyenne des vitesses par répétition — formatée en km/h parformatSpeed(); l’étape 40 a remplacé partout l’ancienne allure en min/km, que l’utilisateur ne lisait pas) ; - liste les exercices autonomes à plat, en fusionnant toutes les occurrences d’un même exercice dans le journal en une seule ligne (une “course” enregistrée 4 fois séparément hors combo devient une ligne unique, distance/temps additionnés, vitesse calculée, et un compteur « ×4 »), et replie chaque combo en une ligne dont le
<details>déplie la répartition par élément (groupCardioItems(), indexé parexercise_idpour les éléments autonomes, parcombo_idpour les tours de combo). Le résumé du<details>affiche « Vue détaillée » (le titre du combo, souvent identique au nom de la séance, doublonnait l’en-tête du dialogue), suivi du titre du combo seulement quand le journal en contient plusieurs ; - ajoute un
<details>replié « Vue agrégée » (aggregateByExercise()) : une ligne parexercise_idsur tout le journal, éléments autonomes et tours de combo fusionnés — nombre, distance, temps, vitesse moyenne ; - se passe entièrement de carte, en gardant les totaux, quand rien n’a été tracé — un tapis de course saisi à la main.
parseTrack() avale volontairement le JSON malformé : un gps_track corrompu ne doit jamais casser l’écran qui l’affiche.
Les cartes « Séances récentes » et les lignes « Historique » de l’accueil portent aussi un bouton de suppression (bi-trash), protégé par le même window.confirm() que pour la suppression d’un exercice ou d’une séance. Il appelle executionLogRepository.delete() (avec suppression en cascade des execution_log_item) et recharge les données de la vue Accueil — séances récentes, historique et compteurs 7/30 jours ensemble, via un _reloadAll() partagé.
Modifier les valeurs cardio après coup
Le crayon de la boîte de dialogue transforme chaque élément cardio en un couple Durée (min) + Distance (km), réécrit via executionLogRepository.updateItemCardio(). Le dénivelé existant de l’élément est repassé tel quel : une distance corrigée à la main ne dit rien de la montée, et la recalculer exigerait une trace que l’exécution n’a peut-être pas. Les valeurs peuvent être ajoutées à une exécution qui n’en avait aucune, et une saisie négative est rejetée avec le message partagé validateMin.
Tout, sur l’accueil, dans l’historique et dans les statistiques, est lu en direct depuis la base : une correction apparaît donc partout sans câblage supplémentaire ; la boîte de dialogue émet updated pour que la vue Accueil ouverte se relise immédiatement.
Export GPX
Le bouton « Exporter en GPX » de TrackMapDialog.vue (affiché dès que le journal a une trace) sérialise la trace enregistrée en document GPX 1.1 via buildGpx() dans src/utils/gpx.js — simple construction de chaîne, sans bibliothèque. Un <trk> par élément cardio enregistré (nommé d’après l’exercice), un <trkseg> par portion continue (splitTrackSegments(), la même fonction déjà utilisée pour dessiner les trous sur la carte).
gps_track stocke le temps de chaque point en tSec, relatif au démarrage du suivi de cet élément (voir useGeoTracking.js), pas en heure absolue. execution_log_item.timestamp (l’heure de fin de l’élément) est la seule horloge absolue stockée par élément : l’export y ancre donc le dernier point enregistré et remonte les points précédents par leurs vrais écarts de tSec — exact même à travers une pause, puisqu’une pause se traduit déjà par un trou dans tSec. Un point sans altitude enregistrée n’a pas d’élément <ele> plutôt qu’une valeur inventée.
Le fichier est téléchargé via le même singleton useSaveFilePrompt() que l’export HTML ci-dessous (SaveFileDialog.vue), avec pour nom par défaut track-AAAA-MM-JJ.gpx d’après le started_at du journal.
C’est le sens lecture des traces GPS — exporter ce qui a été enregistré. L’import GPX (un itinéraire à suivre pendant l’exécution) est une fonctionnalité séparée, à venir (voir docs/plan-step38-guided-route.md).
Export HTML
Un bouton de la barre d’action (bi-filetype-html) ne s’affiche que si au moins un journal d’exécution existe. Cliquer dessus appelle useHtmlExport.exportHomeAsHtml(), qui :
- Récupère
/icon.svget l’encode en URIdata:base64 (TextEncoder+btoa). - Construit un document HTML entièrement autonome à partir de l’état réactif courant — cartes de statistiques, séances récentes, et historique filtré selon la plage de dates active.
- Retire les boutons lecture, les liens de navigation et les sélecteurs de date du résultat (export en lecture seule).
Contrairement à l’écran, il garde le détail
{…}complet des combos cardio, et ajoute sous chaque journal contenant du cardio le même agrégat par exercice que le dialogue de trace (renderCardioAggregateHtml(), qui réutiliseaggregateByExercise()) — l’export est la copie d’archive de ce que l’accueil résume. - Télécharge le fichier sous le nom
kaivalya-trainer-home-YYYY-MM-DD.html.
Fichiers clés : src/views/HomeView.vue, src/composables/useHtmlExport.js, src/db/repositories/executionLogRepository.js, src/utils/dateFormatter.js
3.7 Mode exécution
Vue : SessionExecuteView — #/sessions/:id/execute
Composable : useSessionExecution
Le mode exécution déroule une séance exercice par exercice, en enregistrant les répétitions, le poids et le côté réellement effectués.
Modèle de file d’exécution
Au lancement de l’exécution, useSessionExecution construit une file linéaire à plat d’objets « étape » à partir des éléments de la séance :
- Un élément exercice → 1 étape
- Un élément combo →
totalRounds × Métapes, oùtotalRoundsdépend du type de combo :
| Type de combo | Signification de repetitions | Formule de totalRounds |
|---|---|---|
| NONE/TABATA | Nombre de tours complets | repetitions |
| EMOM | Durée totale (min) | floor(repetitions / M) — chaque exercice = un créneau d'1 min |
| AMRAP | Durée totale (min) | 1 — un seul passage est mis en file ; l’utilisateur répète librement |
Éléments de séance :
1. Exercice A ×3 reps → 1 étape
2. Combo « HIIT » ×2 tours (2 exercices) → 4 étapes (NONE/TABATA)
Tour 1 : Pompes ×10, Squat ×15
Tour 2 : Pompes ×10, Squat ×15
3. Exercice B ×5 reps → 1 étape
File (à plat) :
[0] Exercice A
[1] Pompes — HIIT, Tour 1/2
[2] Squat — HIIT, Tour 1/2
↓ avance automatique (pas le dernier tour)
[3] Pompes — HIIT, Tour 2/2
[4] Squat — HIIT, Tour 2/2
↓ écran de dernier tour : [Continuer →] [+ Un tour de plus]
[5] Exercice B
Pour un combo EMOM avec 2 exercices et repetitions = 6 (= durée de 6 minutes) : totalRounds = floor(6/2) = 3, ce qui produit 6 étapes d’une minute chacune.
Chaque étape porte : exerciseId, exerciseTitle, prefillReps, prefillWeight, prefillSide, comboId?, comboTitle?, roundNumber?, totalRounds?, asymmetric, alternate, isFinalComboStep, comboExercises, ainsi que les champs cardio category, targetUnit, targetDurationSec, targetDistanceM et freeStyle (niveau séance).
Le drapeau isFinalComboStep
Mis à true uniquement sur le dernier exercice du dernier tour d’un combo. Quand l’utilisateur clique sur Terminé sur cette étape, le composable affiche l’écran de dernier tour au lieu d’avancer automatiquement.
Logique de pré-remplissage
À l’initialisation, executionLogRepository.getLastItemsBySession(sessionId) récupère le execution_log le plus récent pour la séance et renvoie une correspondance exerciseId → {reps_done, weight_used, side}. Les étapes utilisent cette correspondance pour le pré-remplissage, et retombent sur les valeurs par défaut de l’élément de séance s’il n’existe aucun journal antérieur.
« + Un tour de plus »
Quand l’utilisateur clique sur + Un tour de plus, le composable :
- Récupère les étapes complétées pour le dernier tour en cours (répétitions/poids réellement utilisés)
- Insère un nouveau lot d’étapes à
currentIndex + 1dans la file - Marque
isFinalComboStep = truesur la dernière nouvelle étape - Avance
currentIndexjusqu’à la première nouvelle étape
Étapes cardio (GPS, carte, dénivelé)
Une étape dont la category de l’exercice vaut cardio affiche CardioExecuteCard.vue — carte, chronomètre ou compte à rebours, bascule GPS — à la place des champs répétitions/poids. La hauteur de .map-wrapper est clamp(140px, 26vh, 220px) plutôt qu’une valeur fixe de 220px, pour se réduire sur un petit écran de téléphone au lieu de repousser les boutons Précédent/Terminé hors de l’écran ; le bureau et la tablette gardent les 220px pleins.
Qui pilote l’horloge. Une étape cardio ne pilote son propre minuteur que si elle est autonome ou dans un combo NONE. En EMOM / AMRAP / TABATA, le minuteur du combo continue de la piloter exactement comme un élément de force, et la carte n’ajoute que le chronomètre et la carte géographique.
| Étape cardio | Horloge | Fin |
|---|---|---|
| Objectif de temps (autonome / combo NONE) | Compte à rebours, sons EMOM + annonce anticipée | Passage automatique à zéro |
| Objectif de distance | Chronomètre, distance en direct + barre de progression | Terminer (jamais auto) |
| Sans objectif, ou séance en style libre | Chronomètre, aucune distance affichée | Terminer |
| Dans un EMOM / AMRAP / TABATA | Le minuteur du combo (inchangé) | Selon le type de combo |
La distance en direct n’est affichée que pour une étape autonome à objectif de distance : dans un combo, le suivi couvre plusieurs éléments, un total à l’écran ne répondrait donc pas à la question que se pose le sportif. Tout est enregistré dans tous les cas et affiché après la séance (31d).
Signaux sonores. Une étape cardio à rebours bipe à la mi-temps puis à 2 s et 1 s, avec un BIP à l’expiration (_cardioSounds, voir § 3.7 Signaux sonores). Une étape qui n’en a pas — style libre, sans objectif, ou avec un objectif de distance — ne lance aucun minuteur, et jusqu’aux correctifs du test terrain du 2026-08-31 elle restait silencieuse du début à la fin, puisque tous les signaux de l’application dépendent d’un tick ou d’une expiration de minuteur. Trois signaux viennent désormais d’ailleurs : un BIP quand l’étape est terminée à la main (markDone), un BIP à l’atteinte de l’objectif de distance, et un bip doux à chaque kilomètre entier, ces deux derniers pilotés par un observateur sur la distance suivie plutôt que par une horloge. La distance de l’étape est mesurée depuis son propre début : une seconde étape cardio dans la même séance n’hérite pas des kilomètres de la première.
Qualité GPS en direct. La carte affiche la précision annoncée par le récepteur et l’ancienneté de la dernière position acceptée (execution.cardio.gpsQuality / gpsAccuracy). Avant les correctifs, un téléphone annonçant 40 m de précision en mode économie d’énergie produisait une carte bloquée sur « En attente d’un point GPS… » sans la moindre explication.
Suivi (useGeoTracking.js) encapsule navigator.geolocation.watchPosition (enableHighAccuracy, maximumAge: 0, timeout 15 s) et filtre le bruit : les positions de précision > 50 m sont écartées, les déplacements de moins de max(3 m, précision / 2) ignorés, et une pause oublie le dernier point pour ne pas tracer une ligne droite par-dessus. La distance s’accumule avec haversineM. Le suivi reste actif tant que la file d’exécution reste sur du cardio ou sur un combo contenant un élément cardio — un élément de force au milieu d’un combo course/marche ne doit pas couper la trace en deux — et markSegment(), appelé à chaque enregistrement d’étape, remet à cette étape exactement les points enregistrés depuis la précédente. Un gps_track par execution_log_item ; la trace de la séance est la concaténation ordonnée de celles de ses éléments.
La barrière de précision était fixée à 30 m avec un seuil de gigue fixe de 3 m. Le test terrain du 2026-08-31 a montré que c’était contre-productif : écran verrouillé, Android bascule sur un mode de localisation économe qui annonce couramment 30 à 50 m — toutes les positions étaient donc écartées en silence pendant que la carte promettait toujours un point à venir. Accepter des positions grossières sans adapter le seuil de gigue aurait simplement transformé leur gigue en distance, d’où le seuil proportionnel à la précision.
Trous dans le flux. Plus de 30 s entre deux positions acceptées n’est pas un déplacement mais un trou — page gelée, ou récepteur ayant perdu le ciel. Le suivi lâche son point d’ancrage, de sorte que le trou n’ajoute aucune distance, et splitTrackSegments() (src/utils/geo.js) permet aux deux composants de carte de tracer chaque portion séparément au lieu d’une ligne droite par-dessus. La coupure se déduit des horodatages tSec des points eux-mêmes : aucun changement de format, et les traces enregistrées avant en bénéficient aussi.
Quelle horloge date une position. Celle du récepteur, pas la nôtre. Android répond au premier appel de chaque surveillance par sa dernière position connue en cache — une estimation wifi/cellulaire qui arrive fraîche quel que soit son âge, ce que maximumAge: 0 n’empêche pas. Le test terrain du 2026-09-01 l’a raccordée à la position réelle suivante et a comptabilisé 1186 m en trois secondes (1423 km/h), déclenchant le signal du kilomètre 2,9 s après geoStart ; la barrière des trous ne l’a jamais vue, puisqu’en temps d’arrivée les deux positions n’étaient séparées que de 3 s. _onPosition lit donc position.timestamp : une position datée de plus de 30 s est écartée d’emblée (geoStaleFix), et la détection des trous comme le tSec du point se mesurent sur cet horodatage — une salve de positions tamponnées puis délivrées au dégel n’est donc plus lue comme un déplacement continu.
Un plafond de vitesse vient en renfort pour les plateformes qui redatent la position en cache à l’heure courante, ce qui contourne la barrière d’ancienneté : un déplacement impliquant plus de 30 m/s (108 km/h) lâche l’ancrage au lieu de le raccorder, et journalise geoTeleport. Ce n’est pas un modèle de la vitesse d’un sportif : à 108 km/h le plafond se situe très au-dessus de tout ce qui est mû par un humain, et les artefacts observés filaient à 1400 km/h — il n’écarte donc jamais que le récepteur. La position, elle, entre bien dans la trace : quelle que soit celle des deux qui est l’artefact, la plus récente est là où se trouve le sportif. Une conséquence cosmétique est assumée : la polyligne relie toujours les deux points, car une trace ne peut être coupée que sur un écart de temps et un téléport n’en consomme aucun.
Réarmement. Android peut laisser une surveillance enregistrée mais muette après le dégel de la page, et les codes 2 (POSITION_UNAVAILABLE) / 3 (TIMEOUT) font de même. clearWatch() + watchPosition() est rejoué sur visibilitychange → visible, sur un délai croissant de 2 s à 30 s après ces deux codes d’erreur, et — depuis le test terrain du 2026-09-01 — sur un chien de garde d’ancienneté : un intervalle de 30 s qui réarme dès que la dernière position date de plus de 45 s, quelle que soit la visibilité de la page, limité à un réarmement par minute. Le code 1 (refus) reste définitif pour la séance.
Ce chien de garde existe parce que les deux autres déclencheurs ne suffisaient pas. Sur les 78 minutes de la séance du 2026-09-01, 34 des 40 éléments de journal ne contiennent aucun gps_track : écran éteint, la surveillance est restée enregistrée et muette jusqu’à 1749 s d’affilée pendant que le battement de cœur, les minuteurs et l’audio de maintien tournaient tous normalement, et chaque reprise est venue du sportif regardant son téléphone. Savoir si une page masquée peut être servie sur cet appareil n’est plus ouvert : elle ne le peut pas, et la raison est une règle de permission Android, pas quoi que ce soit dans ce code — voir le §3.16, qui documente la règle et le mode poche construit autour. Le chien de garde reste parce qu’il est juste et sans coût, et parce que geoRearm reason=stale dans un journal terrain est précisément le marqueur de cette limite.
Carte (Leaflet 1.9.4, BSD-2). La couche de tuiles est choisie une seule fois, d’après la première position : IGN Plan IGN v2 en France (emprise isInFrance), OSM ailleurs. Les tuiles sont demandées avec crossOrigin: 'anonymous' — la production tourne sous COEP require-corp, où une tuile sans CORS est bloquée. Avant la première position, aucune tuile n’est demandée : la carte est remplacée par un cadre gris.
GPS désactivé / mode manuel. La bascule est active par défaut si navigator.geolocation existe, et forcée à l’arrêt avec une indication si l’autorisation est refusée. GPS coupé, la carte affiche un champ de distance en km, et c’est cette valeur saisie qui est enregistrée.
Dénivelé (src/utils/elevation.js) est résolu après l’écriture du journal — _resolveElevationsForLog() est lancé, sans await, depuis _finish()/exit(), car terminer une séance ne doit jamais attendre le réseau. La trace est d’abord sous-échantillonnée à ~1 point tous les 30 m, puis :
- altimétrie IGN (
data.geopf.fr/altimetrie/…&zonly=true&resource=ign_rge_alti_wld, lots de 150) si le premier point est en France ; - Open Topo Data SRTM 30 m (lots de 100, limités à 1 requête/s) ailleurs ;
- en cas d’échec, une moyenne glissante (fenêtre 5) sur les altitudes GPS brutes, marquée
elevationSource: 'gps'.
Le paramètre resource n’est pas facultatif, et son absence n’est pas diagnosticable depuis la réponse : la passerelle Géoplateforme répond 405 Unsupported Request — et non un 400 nommant ce qui manque — de sorte que tant que la requête est partie sans lui, toutes les altitudes en France sont silencieusement passées par le repli GPS. La séance terrain du 2026-09-01 a journalisé elevationLookupFailed message="IGN elevation HTTP 405" inFrance=true sur ses 40 étapes. ign_rge_alti_wld est la ressource RGE ALTI couvrant la France métropolitaine, seul endroit d’où ce point d’entrée est appelé. Le bouchon de la suite de tests répond 405 à une requête ne nommant aucune ressource : la garde porte donc sur tous les tests d’altitude, pas sur un seul.
Le dénivelé positif et négatif ne s’accumulent qu’au-delà d’une hystérésis de ±3 m : l’altitude de référence ne se déplace qu’une fois le profil écarté de plus de 3 m, pour que le bruit d’échantillonnage du MNT sur une route plate n’invente pas des centaines de mètres de montée. Les résultats repassent par executionLogRepository.updateItemElevation(), qui réécrit aussi gps_track avec les altitudes du MNT.
Résilience. Les étapes cardio utilisent le même audio de maintien en vie, le même verrou d’écran et la même session média que les étapes minutées. L’instantané de l’étape 33 porte en plus les points de la trace, la distance accumulée, le temps écoulé et l’état de la bascule GPS, écrit au plus une fois toutes les 5 s depuis le tick du chronomètre (une position arrive environ chaque seconde ; sérialiser une trace d’une heure à chaque fois serait du gaspillage). Une interruption en pleine course reprend avec ses kilomètres intacts.
La ligne existe avant la fin de l’étape. Un instantané ne sert que pour un arrêt dont l’utilisateur accepte ensuite la reprise. Depuis les correctifs du 2026-08-31, l’execution_log_item d’une étape cardio est ouvert au démarrage de l’étape, réécrit par _flushCardioProgress() sur le même seuil de 5 s (executionLogRepository.updateItemProgress() écrit duration_sec, distance_m, gps_track et completed en une instruction), puis finalisé sur place à l’enregistrement. Un arrêt coûte donc au pire une fenêtre d’écriture, et non plus la course entière.
Trois conséquences à énoncer explicitement :
- Les lignes sont écrites
completed = 1dès la première écriture. L’alternative — un marqueur « en cours » filtré parisCardioItem()et par les requêtes de statistiques — masquerait précisément la course que ce mécanisme protège, puisqu’un arrêt brutal ne revient jamais la finaliser. Une course inachevée reste une donnée réelle. exit()enregistre avant d’abandonner. Appuyer sur X jetait purement et simplement l’étape cardio en cours ; c’est ainsi qu’une marche de 34 minutes a disparu lors du test terrain, faute d’avoir appuyé sur Terminer. L’étape est désormais finalisée dès qu’elle a enregistré quelque chose (temps écoulé, distance ou points), et le log n’est marquéabortedqu’ensuite. Une étape qui n’a rien enregistré voit sa ligne supprimée, tout comme celle d’une étape quittée par Précédent ou coupée par l’expiration d’un AMRAP.- Les écritures intermédiaires ne peuvent pas écraser les valeurs finales. Une écriture intermédiaire réécrit toute la ligne : celle qui se terminerait après la finalisation l’annulerait.
_flushCardioProgress()ne garde qu’une écriture en vol et abandonne si l’étape a commencé à être finalisée pendant son attente, ou si la ligne visée n’est plus la ligne ouverte.
Cartes hors ligne (cache d’exécution + packs de cartes)
Deux mécanismes indépendants, volontairement non couplés.
1 — Cache de tuiles à l’exécution (passif). Deux entrées runtimeCaching dans vite.config.js (kaivalya-trainer-osm-tiles, kaivalya-trainer-ign-tiles), toutes deux CacheFirst, 2000 entrées, 90 jours. Elles sont déclarées avant la règle générique request.destination === 'image', car Workbox retient la première route qui correspond : sinon les tuiles atterriraient dans le cache kaivalya-trainer-exercise-images limité à 300 entrées et en évinceraient les photos d’exercices. Rien d’autre n’est nécessaire : une zone déjà consultée s’affiche hors ligne.
2 — Packs de cartes (téléchargement explicite). Un pack est { id, name, bounds, minZoom: 10, maxZoom, createdAt, tileCount, sizeBytes }, persisté en JSON dans settings.map_packs — aucun changement de schéma. La liste des tuiles n’est jamais stockée : elle est redérivée des bornes par enumerateTiles() à chaque besoin, ce qui permet de répondre à « quelles tuiles appartiennent à ce pack ? » au moment de la suppression sans avoir écrit des milliers d’URL en base.
| Élément | Rôle |
|---|---|
src/utils/mapPacks.js | Géométrie et dimensionnement purs : enumerateTiles, estimatePack, formatBytes |
useMapPacks.js | Écritures Cache Storage, persistance des métadonnées, progression, suppression |
MapPackDialog.vue | Carte de cadrage, choix du zoom, estimation, barre de progression |
mapLayers.js | createTileLayer() — la couche Leaflet consciente des packs, utilisée partout |
Servir les tuiles téléchargées. generateSW maîtrise le routage du service worker et ignore tout des packs ; la recherche vit donc dans la couche de tuiles : PackAwareTileLayer.createTile() tente d’abord caches.match(url) dans le bucket kaivalya-trainer-map-packs et sert le résultat en blob URL (révoquée dans _removeTile), sinon il effectue une requête réseau normale. Comme createTileLayer() est désormais le seul endroit où une couche de tuiles est créée, les packs fonctionnent aussi bien sur la carte d’exécution que sur la relecture de trace.
Choisir la zone. Le cadrage de la carte est la sélection : on déplace et on zoome, et ce qui est à l’écran est ce qui sera téléchargé. Pas de rectangle à tracer, et le zoom déjà utilisé pour regarder la zone est celui qui est enregistré. Le nombre de tuiles et une estimation de taille (nombre × ~15 Ko) sont affichés avant tout téléchargement, et un pack de plus de 500 Mo déclenche un avertissement.
Les packs sont réservés à l’IGN : la politique d’usage des tuiles OpenStreetMap interdit le téléchargement en masse, donc une zone dont le centre est hors de isInFrance() est bloquée avec une explication plutôt que téléchargée silencieusement depuis OSM.
Concurrence et échecs. Les tuiles sont récupérées quatre à la fois, avec possibilité d’interrompre ; les échecs individuels sont comptés, pas fatals (un pack auquel il manque quelques tuiles s’affiche quand même, il passe simplement par le réseau à cet endroit). Supprimer un pack n’enlève que les tuiles dont aucun autre pack n’a besoin, les zones qui se recouvrent étant le cas normal.
Une instance par écran. La carte Réglages et la boîte de dialogue de téléchargement appellent chacune useMapPacks(), donc chaque mutation relit la liste stockée dans settings avant de l’écrire. Muter packs.value directement laissait la boîte de dialogue persister une liste qui n’avait jamais contenu les packs affichés par la carte — c’est exactement ce que faisait la première version.
Un téléchargement de pack réchauffe aussi le cache d’exécution kaivalya-trainer-ign-tiles, puisque le fetch() passe par le service worker comme toute autre requête. Ce bucket étant plafonné à 2000 entrées en LRU, la duplication reste bornée et se résorbe d’elle-même.
Gestion du côté pour les exercices asymétriques
Si asymmetric = true et alternate = true, le côté change à chaque étape du même exercice. Si alternate = false, il change après chaque série complète. La valeur finale du côté est enregistrée dans execution_log_item.side.
Journal partiel en cas de sortie
Cliquer sur ✕ (sortir) enregistre toutes les étapes complétées dans execution_log et omet les étapes non terminées. Le journal est marqué avec finished_at = null pour indiquer une exécution partielle. Les éléments complétés restent comptabilisés dans les statistiques.
Bascule du détail de l’exercice
Le titre de l’exercice dans la carte de l’étape en cours est un bouton à bascule. Cliquer dessus :
- Déplie un panneau de détail intégré (description rendue en Markdown, images, liens vidéo).
- Met en pause le chronomètre actif — mais seulement s’il tournait au moment du clic (le drapeau
detailPausedTimerle retient).
Replier le panneau ne relance le chronomètre que si detailPausedTimer est vrai, laissant intacts les chronomètres mis en pause manuellement.
Le panneau de détail est chargé à la demande, au premier dépliage, via exerciseRepository.getById(step.exerciseId). Les bascules suivantes au sein de la même étape réutilisent le detailExercise mis en cache. Un watch(currentStep, …) réinitialise showDetail, detailExercise et detailPausedTimer à chaque avancée d’étape.
Pour la détection de l’état de connexion (afficher ou non les liens vidéo), SessionExecuteView utilise useOnlineStatus(). L’extraction de l’ID YouTube utilise l’utilitaire partagé src/utils/youtube.js (la même fonction que dans ExerciseDetailView).
Disposition de l’interface
┌─────────────────────────────────────────┐
│ Titre de la séance [✕ sortir] │
│ ████████████░░░░░ 3 / 6 étapes │
├─────────────────────────────────────────┤
│ ▶ MAINTENANT │
│ ┌───────────────────────────────────┐ │
│ │ Pompes ˅ │ │ ← le titre est un bouton bascule
│ │ ┌─────────────────────────────┐ │ │ ← panneau de détail (si déplié)
│ │ │ Gardez le corps droit… │ │ │
│ │ │ [image] [image] │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ Combo : HIIT — Tour 2 sur 2 │ │
│ │ Reps : [ 10 ] │ │
│ │ Poids : [ 0 ] kg │ │
│ │ Côté : DROIT ↔ │ │ ← asymétrique uniquement
│ │ [ ✓ Terminé ] │ │
│ └───────────────────────────────────┘ │
│ ↓ SUIVANT │
│ Squat — 15 reps (HIIT, Tour 2) │
└─────────────────────────────────────────┘
Signaux sonores
src/composables/useAudioCues.js fournit deux fonctions construites sur la Web Audio API :
| Fonction | Fréquence | Durée | Rôle |
|---|---|---|---|
playBip(volume) | 440 Hz | 0.1 s | Tic discret — décompte, mi-parcours |
playBIP(volume) | 880 Hz | 0.3 s | Signal fort — changement de phase, fin du temps |
AudioContext est créé de façon différée, au premier usage (singleton). Le volume est lu depuis settings.audio_volume (0–100) et converti en multiplicateur 0.0–1.0.
La logique des sons vit dans useSessionExecution sous forme de quatre fonctions privées :
_emomSounds(remaining)— déclencheplayBipà r = 30, 2, 1 (EMOM uniquement)._amrapLastMinuteSounds(remaining)— déclencheplayBipà r = 30, 2, 1 (dernière minute de l’AMRAP)._tabataCountdown(remaining, totalTime)— déclencheplayBipàfloor(totalTime / 2), r = 2, et r = 1._cardioSounds(remaining, totalTime)— déclencheplayBipàfloor(totalTime / 2), r = 2 et r = 1, et pré-annonce l’étape suivante à r = 10 (rebours cardio uniquement).
Un rebours cardio empruntait auparavant _emomSounds, et son signal à 30 s tombait presque sur la fin : la fenêtre EMOM dure 60 s, où 30 s est la mi-temps, mais une étape cardio se compte en minutes entières. _cardioSounds déplace ce signal à la mi-temps — « demi-tour » — et laisse EMOM, AMRAP et TABATA strictement inchangés.
Logique du minuteur AMRAP dans _maybeStartAmrapTimer :
r > 60 :
changement de minute écoulée → playBip (tic par minute)
r === round(duration / 2) → playBIP (signal mi-temps ; prioritaire sur le tic minute)
r ≤ 60 :
_amrapLastMinuteSounds(r)
r = 0 (expiration) :
playBIP (signal de fin de temps)
La séquence bip, bip, BIP (r = 2, r = 1, r = 0/expiration) est espacée régulièrement d’une seconde, car onTick se déclenche à r = 2 et r = 1, et onExpire se déclenche à r = 0 — chacun exactement à un tic de minuteur d’écart.
Notification multimédia synchronisée et repos sautable (Step 35)
Les touches multimédia (casque, écran verrouillé, notification Android) ne sont routées par le navigateur que vers une page lisant activement de l’audio via un élément média — cet élément est l’audio de maintien du Step 34 (§3.15), démarré par useSessionExecution.js. useMediaSession.js jouait à l’origine sa propre seconde boucle silencieuse (un WAV data-URI de 50 ms) comme ancre — supprimée au Step 35 : sous le minimum de 3 s du média contrôlable de Gecko, Firefox ancrait la notification Android sur l’élément de maintien pendant que les métadonnées étaient déclarées autour de l’élément inéligible, laissant la notification avec un libellé générique périmé. Une page, un seul élément d’ancrage.
SessionExecuteView.vue synchronise désormais la notification avec l’exécution : _syncMediaMetadata() (observé sur [currentStep, timerPhase]) affiche le nom de l’exercice en cours avec « Suivant : X » en sous-titre, ou pendant une phase de repos TABATA le libellé REPOS avec ce même sous-titre « Suivant : X » (retour utilisateur du même jour : le sous-titre n’apparaissait qu’au repos, il est désormais affiché aussi pendant l’exercice). Pendant une phase de travail TABATA, X est le libellé REPOS localisé plutôt que le prochain exercice de la file : le repos n’est pas une entrée à part dans la file mais une phase de l’étape en cours, donc c’est bien le repos qui arrive ensuite, pas l’exercice qui le suit (correction apportée le jour même après un retour utilisateur signalant l’erreur) — le titre du combo (ou de la séance hors combo) ne sert de repli que lorsqu’il n’y a vraiment rien à annoncer (dernière étape, hors TABATA) ; un watch(timerPaused, …) déclare playbackState 'paused'/'playing' quelle que soit l’origine du changement (notification, bouton pause à l’écran, auto-pause du panneau de détail) — auparavant seul le gestionnaire multimédia le faisait, et le bouton de la notification affichait ▶ pendant que la séance tournait.
Déclarer l’état ne suffit pas : selon la spécification (et MediaStatusManager dans Gecko), l’état affiché est déclaré === 'playing' ? 'playing' : deviné, où l’état deviné provient des éléments média réels — un 'paused' déclaré perd face à un audio toujours en lecture. togglePause() dans useSessionExecution.js met donc aussi réellement en pause (et reprend) l’élément de maintien. Compromis assumé : pendant une pause, la protection anti-gel en arrière-plan est inactive — mais aucun minuteur ne tourne pendant une pause, et le flux instantané/reprise couvre un arrêt ; Gecko garde un média en pause contrôlable (notification + gestionnaires) pendant 24 h (media.mediacontrol.stopcontrol.timer.ms).
Boutons suivant/précédent dans les commandes multimédia Android : Fenix n’annonce ACTION_SKIP_TO_NEXT/_PREVIOUS à Android que si son drapeau mediaNotificationImprovements (un déploiement Nimbus côté Mozilla) est activé — une page web ne peut pas l’influencer. Nos gestionnaires nexttrack/previoustrack sont enregistrés et fonctionnent déjà via les touches multimédia casque/Bluetooth ; les boutons apparaîtront d’eux-mêmes dans la notification quand Mozilla activera le drapeau.
L’action nexttrack (bouton ⏭) valide l’exercice en cours ; pendant un repos TABATA elle le saute désormais via skipRest() (nouveau dans useSessionExecution.js) : arrêt du minuteur de repos puis exactement le même aboutissement qu’une expiration naturelle (écran de fin de tour si l’étape était la dernière du combo, sinon passage à l’étape suivante). Le même saut est disponible à l’écran via un bouton Passer le repos (bi-skip-end-fill, data-testid="execute-skip-rest-btn") sur la carte de repos, dans le style du bouton Terminé — la fonctionnalité ne nécessite donc ni casque ni notification pour être utilisée ou testée.
Fichiers clés : src/views/SessionExecuteView.vue, src/composables/useSessionExecution.js, src/composables/useAudioCues.js, src/composables/useMediaSession.js, src/db/repositories/executionLogRepository.js, src/utils/youtube.js
3.8 Statistiques
Vue : StatsView — #/stats
Composable : useStats
Bibliothèques : Chart.js 4 + vue-chartjs 5, enregistrées localement dans StatsView.vue (pas globalement, pour éviter des effets de bord sur les autres routes).
Filtre de plage de dates
Quatre onglets contrôlent la période affichée : 7j, 30j (par défaut), 3m, Tout.
Cartes de résumé
| Carte | Requête |
|---|---|
| Séances | Nombre de lignes execution_log sur la période |
| Exercices réalisés | Nombre de lignes execution_log_item (complétées) sur la période |
| Série de jours | Jours calendaires consécutifs (jusqu’à aujourd’hui) avec au moins un journal complété (les séances abandonnées ne comptent pas) ; toujours calculée sur tout l’historique |
La série utilise setUTCHours(0,0,0,0) pour comparer les dates et correspondre à l’extraction UTC DATE(started_at) de SQLite.
Graphiques
| Graphique | Type | Métrique |
|---|---|---|
| Fréquence | Barres | Séances par semaine ou par mois (au choix) |
| Volume | Barres | SUM(reps_done × weight_used) par exécution de séance |
| Progression | Ligne | Répétitions (axe gauche) + poids max (axe droit) au fil du temps pour un exercice donné |
Le volume ne compte que les exercices avec poids (poids > 0). Le graphique de progression ne liste que les exercices ayant au moins un journal d’exécution.
Bascule renforcement / cardio
L’endurance et le renforcement ne répondent pas aux mêmes questions — kilomètres et dénivelé d’un côté, répétitions et charge de l’autre — et les mélanger dans une seule colonne de graphiques diluerait les deux. Une bascule à deux boutons est placée juste sous les cartes de résumé et échange toute la zone d’analyse entre un panneau Renforcement (fréquence, volume, progression — inchangés) et un panneau Cardio. Les cartes de résumé et le filtre de plage de dates restent au-dessus de la bascule : ils décrivent l’historique dans son ensemble et s’appliquent aux deux panneaux.
La bascule est toujours affichée, même sans aucune donnée cardio, pour que la fonctionnalité reste découvrable ; le panneau cardio affiche alors une simple ligne d’état vide plutôt qu’un mur de zéros.
Panneau cardio
| Bloc | Contenu |
|---|---|
| Totaux (4 cartes) | Distance, temps, dénivelé positif (D+), dénivelé négatif (D−) |
| Distance dans le temps | Courbe, un point par jour ayant enregistré du cardio |
| Par jour | Une ligne par jour calendaire (UTC, comme tous les autres regroupements) |
| Par séance | Une ligne par exécution, libellée par le titre de la séance + la date |
| Par combo | Une ligne par combo, avec le nombre d’exécutions concernées |
| Par exercice | Une ligne par exercice cardio — les splits par élément enregistrés en 3.7 |
Les quatre agrégations partagent un seul constructeur SQL (_cardioSource() dans executionLogRepository) et une seule liste SELECT (CARDIO_SUMS) : elles lisent exactement les mêmes lignes — éléments completed = 1, dont l’exercice a category = 'cardio', et qui ont enregistré une distance_m ou une duration_sec — et ne diffèrent que par leur GROUP BY et leur jointure de libellé. Une sortie sur tapis saisie à la main, sans trace GPS, est donc comptée comme les autres.
Les totaux sont dérivés des lignes « par jour » plutôt que d’une cinquième requête SUM : mêmes lignes, même filtre, donc une requête séparée ne pourrait que finir par contredire le tableau affiché juste en dessous.
La section « Par combo » est masquée quand aucun cardio n’a été réalisé dans un combo — contrairement aux trois autres, un tableau de combos vide n’apprend rien.
Le rendu est factorisé dans CardioStatsTable.vue : les quatre niveaux ne diffèrent que par leur colonne de libellé, donc StatsView normalise chaque jeu de résultats en { key, label, sublabel, distanceM, durationSec, gainM, lossM, paceSecPerKm } et le composant dessine le tableau, avec une colonne Allure (formatPace(), même formule que totals() — durationSec / (distanceM / 1000) — une cellule vide affiche « — » plutôt que NaN/Infinity quand l’une des deux valeurs est nulle). « Par exercice » additionnait déjà chaque occurrence d’un exercice dans une séance via son simple GROUP BY e.id — plusieurs éléments autonomes du même exercice dans une même séance étaient déjà totalisés ici — c’est donc la colonne allure qui manquait réellement pour répondre à « quelle a été ma vitesse moyenne sur cet exercice ». L’étape 40 a transformé cette colonne en Vitesse (km/h, speedKmh() partagé avec totals()).
Les cartes de l’accueil raccourcissent les combos cardio en Titre ×tours (isCardioCombo() dans HomeView.vue, un test par groupe pour qu’une séance mixte force + cardio garde son détail force) ; un combo fractionné de 23 tours remplissait sinon la carte.
Graphique de progression cardio
CardioProgressChart.vue est placé en haut du panneau cardio, hors de la condition hasCardioData et indépendant des boutons de plage de dates de la page, car il prend des dates arbitraires ou une seule exécution. L’état vit dans useCardioProgress.js ; le composant ne fait que transformer points en jeux de données Chart.js.
- Les bornes de plage sont des minuits locaux convertis en ISO, borne haute exclusive (
to + 1 jour), puisquestarted_atest stocké en UTC. - Les unités diffèrent, donc chaque mesure cochée a son propre axe Y (le premier à gauche avec la grille, les autres à droite). Les mesures gardent l’ordre fixe
CARDIO_MEASURESquel que soit l’ordre des clics ; la dernière case cochée est désactivée plutôt que de refuser le clic en silence (un changement refusé sur une ref inchangée laissait la case décochée dans le DOM). - Axe X de catégories (date courte, ou
#npar répétition) — l’échelle temporelle de Chart.js exigerait un adaptateur de dates que l’application n’embarque pas. - Le conteneur du graphique expose
data-point-countetdata-measures: les tests ne peuvent pas lire un canvas. - Les quatre requêtes réutilisent
_cardioSource(), donc le graphique compte exactement les lignes que comptent les tableaux.
Date de création de la base
Affichée en haut sous la forme « Vous utilisez Kaivalya Trainer depuis le [date] », lue depuis settings.db_created_at. Formatée via dateFormatter.js et réactive aux changements de langue.
Fichiers clés : src/views/StatsView.vue, src/components/CardioStatsTable.vue, src/components/CardioProgressChart.vue, src/composables/useCardioProgress.js, src/composables/useStats.js, src/db/repositories/executionLogRepository.js, src/utils/cardioFormat.js, src/utils/dateFormatter.js
3.9 Import / Export (JSON)
Composables : useJsonExport, useJsonImport
Composants : ImportDialog, ExportFilterDialog, ModalDialog
Utilitaires : src/utils/jsonEnvelope.js
Format de l’enveloppe JSON
{
"metadata": {
"appName": "kaivalya-trainer",
"appVersion": "1.0.0",
"schemaVersion": 1,
"exportDate": "2026-03-30T12:00:00.000Z",
"exportedBy": { "name": "User Name", "uuid": "uuid-string" }
},
"data": {
"exercises": [...],
"combos": [...],
"sessions": [...],
"collections": [...],
"executionLogs": [...],
"settings": [...]
}
}
Seules les clés présentes dans data sont exportées/importées. Les exports d’une seule entité (par exemple, un seul exercice exporté) n’incluent que le tableau de cette entité.
Points d’entrée d’export
| Point d’entrée | Portée |
|---|---|
| Vue liste Exercice / Combo / Séance / Collection | Tous les éléments de ce type d’entité, ou seulement ceux filtrés si une recherche est active |
| Vue détail Exercice / Combo / Séance / Collection | Un seul élément |
| Réglages → Exporter des données | N’importe quelle combinaison de types d’entité (boîte de filtre) |
La boîte de filtre affiche des cases à cocher par type d’entité. L’export complet depuis Réglages produit kaivalya-trainer-full-YYYY-MM-DD.json.
Les exports depuis une vue liste transmettent la requête de recherche active (le cas échéant) à exportAllExercises/Combos/Sessions/Collections(query) dans useJsonExport.js, qui utilisent search()/searchWithExercises()/searchWithItems()/searchWithSessions() au lieu de getAll()/getAllWithX() quand une requête est présente.
Les exports de combo/séance/collection (unitaire, liste, ou boîte de filtre) ne contiennent jamais que la ligne de l’entité demandée — collectComboDependencies/collectSessionDependencies/collectCollectionDependencies dans useJsonExport.js parcourent les références FK imbriquées (les items d’une séance, les exercices d’un combo, les séances d’une collection) pour récupérer les entités dépendantes complètes et les ajouter comme tableaux exercises/combos/sessions au niveau racine de l’enveloppe. exportByFilter enchaîne cette logique collections → séances → combos → exercices afin qu’une sélection partielle des cases à cocher produise quand même un fichier important de façon autonome, sans référence manquante sur un appareil qui n’a pas déjà ces dépendances.
Points d’entrée d’import
| Point d’entrée | Notes |
|---|---|
| Vue liste Exercice / Combo / Séance / Collection | Import par entité, depuis la barre d’action |
| Réglages → Importer des données | Enveloppe multi-entités prise en charge |
Tous les points d’entrée d’import passent par le même flux généralisé analyzeAll / applyAllImport, y compris les fichiers d’exercice à entité unique (un flux historique séparé existait autrefois, mais produisait en fait une forme d’analyse identique, et a donc été supprimé).
Stratégie de gestion des conflits
| Scénario | Comportement |
|---|---|
Même utilisateur (created_by = user_uuid local), pas de correspondance d’ID/titre | Insertion avec l’ID d’origine |
| Même utilisateur, même ID existant | Invite : Remplacer / Ignorer (avec le lot Remplacer tout / Ignorer tout) |
| Même utilisateur, ID différent mais même titre qu’une ligne existante | Même invite Remplacer/Ignorer, résolue contre l’ID de la ligne existante ; Ignorer redirige les références du même lot vers elle |
| Utilisateur différent, UUID inconnu | Demande un suffixe → enregistré dans la table suffix |
Utilisateur différent, même ID + même created_by | Mise à jour en place (pas de nouvel ID) |
Utilisateur différent, même ID + created_by différent | Génère un nouvel UUID |
| Utilisateur différent, titre suffixé en conflit avec une ligne existante | Même invite Remplacer/Ignorer (pas d’étape de renommage séparée) — presque toujours une réimportation d’un élément déjà importé du même utilisateur externe |
Format de titre pour les imports externes : "Titre original [SUFFIXE]".
Remappage des références entre entités
Quand l’ID d’une entité change pendant l’import (nouvel UUID généré), toutes les entités qui y font référence sont mises à jour :
combo_exercise.exercise_idsession_item.item_id(pour les exercices et les combos)collection_session.session_idexecution_log.session_idexecution_log_item.exercise_id
Restrictions sur l’import des réglages
Les réglages sont importés avec des exclusions, pour préserver l’identité locale :
| Clé | Importée ? |
|---|---|
user_uuid | Non — toujours préservée |
app_version | Non — toujours préservée |
db_created_at | Non — toujours préservée |
last_backup_at | Non — toujours préservée |
user_name, language, theme, backup_reminder_days | Oui |
Import des journaux d’exécution
Les journaux d’exécution ne sont importés que si la séance référencée existe localement ou a été importée dans le même lot.
Flux de la boîte de dialogue d’import
Gestion des suffixes
La page Réglages liste tous les suffixes enregistrés (depuis la table suffix). Renommer un suffixe se propage à toutes les colonnes titre/libellé des entités via le REPLACE() SQL :
UPDATE exercise SET title = REPLACE(title, '[OLD]', '[NEW]') WHERE title LIKE '%[OLD]%'
Appliqué aux tables exercise, combo, session et collection.
Fichiers clés : src/composables/useJsonExport.js, src/composables/useJsonImport.js, src/utils/jsonEnvelope.js, src/components/ImportDialog.vue, src/components/ExportFilterDialog.vue, src/db/repositories/suffixRepository.js
3.10 Sauvegarde
Composable : useBackupStatus (refs singleton au niveau module)
Composant : BackupReminder (téléporté dans #actionbar-actions)
Utilitaire : src/utils/backupFilename.js
Format du nom de fichier
kaivalya-trainer-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3
Heure locale, avec des zéros de remplissage. Les lettres h, m, s remplacent les deux-points pour la compatibilité avec le système de fichiers Windows.
Clés de réglages
| Clé | Défaut | Écrite par |
|---|---|---|
db_created_at | Réglée au premier lancement | database._initDefaults() |
last_backup_at | Absente | Succès de téléchargement, succès de restauration |
backup_reminder_days | "7" | Champ numérique des Réglages |
Détection d’ancienneté
effectiveDate = last_backup_at OU db_created_at (repli)
isStale = (effectiveDate absente) OU (now_utc − effectiveDate) > backup_reminder_days × 86400s
db_created_at comme repli donne aux nouveaux utilisateurs une période de grâce égale à l’intervalle de rappel, avant que l’avertissement n’apparaisse.
Interface du rappel
BackupReminder.vue est monté une seule fois dans App.vue (dans la branche « base prête ») et se téléporte dans #actionbar-actions. Il affiche un bouton de style avertissement avec l’icône bi-exclamation-triangle-fill. Cliquer dessus navigue vers #/settings et fait défiler jusqu’à l’ancre #settings-backup-section. Le rappel ne peut pas être masqué.
useBackupStatus utilise des refs au niveau module, afin que le même état soit partagé entre toutes les instances de composant (pattern singleton).
Formatage des dates
src/utils/dateFormatter.js fournit deux fonctions pures, sensibles à la locale, utilisées partout dans l’application. Les deux délèguent à Intl.DateTimeFormat avec une correspondance fixe langue → BCP-47 (en → en-US, fr → fr-FR, da → da-DK, la même que useSpeech.js) et jour/mois sur 2 chiffres, pour une sortie déterministe par langue quelle que soit la locale du système :
| Fonction | Anglais | Français | Danois |
|---|---|---|---|
formatDate(date, lang?) | MM/DD/YYYY | DD/MM/YYYY | DD.MM.YYYY |
formatDateTime(date, lang?) | date + hh:mm:ss AM/PM | date + HH:MM:SS | date + HH.MM.SS |
Le paramètre optionnel lang permet aux propriétés calculées (computed) de Vue de passer la ref réactive de langue, pour que l’affichage des dates se mette à jour immédiatement lors d’un changement de langue.
Fichiers clés : src/composables/useBackupStatus.js, src/components/BackupReminder.vue, src/utils/backupFilename.js, src/utils/dateFormatter.js
3.11 PWA
Plugin : vite-plugin-pwa + Workbox
Service worker
| Configuration | Valeur | Justification |
|---|---|---|
registerType | autoUpdate | Active silencieusement le nouveau SW à la navigation suivante |
maximumFileSizeToCacheInBytes | 10 Mo | Couvre sqlite3.wasm (~8 Mo) |
manifest | false | Conserve le public/manifest.json écrit à la main |
Workbox gère la précache, basée sur le hash du contenu, de toute la coquille de l’application au moment de l’installation, ce qui permet un support hors ligne complet. Le binaire WASM est inclus dans le manifeste de précache grâce à la limite de 10 Mo.
Stockage persistant
Au démarrage, main.js appelle navigator.storage.persist() une seule fois, sans attendre le résultat. Sans cet appel, tout le stockage du site est « au mieux » (best-effort) : le navigateur peut évincer le Cache Storage (la précache Workbox, y compris la police d’icônes) et la base SQLite sur OPFS après une longue période d’inactivité ou en cas de pression sur le disque. Une demande accordée exempte l’origine de l’éviction automatique. Chromium décide silencieusement selon des heuristiques d’engagement ; Firefox peut afficher une demande de permission ; un refus (ou un navigateur sans cette API) ne bloque jamais le démarrage — le résultat est seulement journalisé.
Manifeste de l’application
public/manifest.json déclare name, short_name, display: standalone, theme_color, background_color, et deux icônes (192×192 et 512×512) avec les usages maskable et any.
Invite d’installation
useInstallPrompt.js capture l’événement beforeinstallprompt au chargement du module (ref singleton canInstall). InstallPrompt.vue téléporte un bouton d’icône de téléchargement dans la barre d’action quand canInstall est vrai. L’invite peut être ignorée pour la session (pas besoin de persistance).
Accessibilité
Ajouts ARIA appliqués dans toute l’application :
aria-labelsur tous les boutons icône-seule de la barre d’action, sur toutes les vuesrole="progressbar"+aria-valuenow/min/maxsur la barre de progression d’exécutionrole="alert"+aria-describedbysur les messages d’erreur de formulairerequired/aria-requiredsur les champs requisrole="dialog"+aria-modal+aria-labelledby+ focus au montage + touche Échap surModalDialog
Fichiers clés : vite.config.js, public/manifest.json, src/composables/useInstallPrompt.js, src/components/InstallPrompt.vue
3.12 Invite de nom de fichier à la sauvegarde
Chaque fichier enregistré sur disque (exports JSON, export HTML de l’accueil, sauvegarde SQLite — y compris la sauvegarde forcée avant migration) demande un nom de fichier, plutôt que de se télécharger silencieusement sous un nom généré automatiquement.
Composable : useSaveFilePrompt (singleton au niveau module, même pattern que useBackupStatus). promptFilename(defaultFilename) découpe le nom par défaut en radical + extension au niveau du dernier point, le stocke dans la ref singleton pendingSave, et renvoie une Promise<string|null> résolue une fois la boîte de dialogue fermée (null en cas d’annulation).
Composant : SaveFileDialog.vue, monté une seule fois à la racine de App.vue, pour être disponible quelle que soit la vue (ou l’écran de migration bloquant) qui déclenche une sauvegarde. Affiche un champ radical modifiable (pré-rempli, texte sélectionné) à côté d’une extension fixe en lecture seule. Entrée valide ; un radical vide retombe sur la valeur par défaut.
Les trois fonctions de téléchargement de bas niveau (downloadJson dans jsonEnvelope.js, downloadBackup dans useBackupDownload.js, la fonction locale downloadHtml dans useHtmlExport.js) appellent promptFilename() avant de construire le blob/lien, et ne font rien si l’utilisateur annule — aucun autre point d’appel n’a changé. Pour les sauvegardes en particulier, markBackedUp() (et, sur l’écran de migration, l’activation du bouton Mettre à jour) ne s’exécute que si la sauvegarde n’a pas été annulée.
Fichiers clés : src/composables/useSaveFilePrompt.js, src/components/SaveFileDialog.vue, src/utils/jsonEnvelope.js, src/composables/useBackupDownload.js, src/composables/useHtmlExport.js, src/App.vue
3.13 Invite de mise à jour de l’application
Le service worker était initialement enregistré avec registerType: 'autoUpdate' : lors du déploiement d’une nouvelle version, le nouveau SW téléchargeait tout le précache (y compris les ~8 Mo de sqlite3.wasm), prenait le contrôle et rechargeait la page — l’utilisateur vivait un gel inexpliqué. (L’écran de migration du §2 ne couvre que les changements de schéma de base de données, pas les mises à jour du code de l’application.)
registerType est maintenant 'prompt' : le nouveau SW s’installe (précache) toujours en arrière-plan, mais attend. L’activation devient une décision de l’utilisateur, présentée via une boîte de dialogue.
Composable : useAppUpdate.js — un singleton au niveau module encapsulant useRegisterSW de virtual:pwa-register/vue (importer le module virtuel remplace aussi le script d’enregistrement auto-injecté par le plugin). Il expose :
needRefresh— ref deuseRegisterSW, vraie quand un nouveau SW est installé et en attenteapplyUpdate()— activeupdating, puis appelleupdateServiceWorker(true); la page se recharge quand le nouveau SW prend le contrôle, doncupdatingn’est jamais réinitialiséedismiss()— rejet valable pour la session uniquement (ref simple, même pattern queInstallPrompt) ; la boîte de dialogue réapparaît au prochain chargement de l’application si la mise à jour est toujours en attentecheckForUpdate()/checkingForUpdate— voir « Vérification manuelle » ci-dessous
Composant : AppUpdateDialog.vue, monté une seule fois dans la branche « BD prête » d’App.vue. Deux états :
- Choix — titre, court texte, boutons Mettre à jour / Plus tard.
- Mise à jour en cours — spinner + texte « Mise à jour en cours… », non fermable :
ModalDialogreçoit aucun titre (ce qui supprime le bouton de fermeture de l’en-tête) et l’événementcloseest ignoré tant queupdatingest vrai (Échap et clic hors modale sont absorbés).
La boîte de dialogue est masquée tant que la route courante est l’écran d’exécution (route.name === 'session-execute') afin qu’une invite de mise à jour n’interrompe jamais un entraînement ; elle s’affiche automatiquement dès que l’utilisateur quitte cet écran.
Tests : simuler une vraie mise à jour de SW dans Playwright nécessiterait deux builds de production, donc useAppUpdate.js expose un point d’ancrage réservé au développement (window.__appUpdateTest, protégé par import.meta.env.DEV) qui permet aux tests de forcer needRefresh et de simuler l’appel de mise à jour. Le flux réel de mise à jour (déployer l’ancien build → déployer le nouveau → invite → mise à jour → rechargement) est validé manuellement.
Vérification manuelle (étape 27) : le navigateur ne re-télécharge le script du SW que selon son propre calendrier (à peu près à la navigation/au rechargement), donc une PWA laissée ouverte longtemps peut manquer une notification de mise à jour pendant un moment. Le callback onRegisteredSW(swScriptUrl, registration) de useRegisterSW capture le registration actif dans une variable au niveau module ; checkForUpdate() appelle registration.update() pour forcer ce re-téléchargement à la demande, en activant checkingForUpdate autour de l’appel. Si le script a changé, Workbox bascule needRefresh exactement comme lors d’une détection passive, donc AppUpdateDialog apparaît avec son choix habituel Mettre à jour / Plus tard — il n’y a pas de chemin de code séparé « trouvé via vérification manuelle », donc une mise à jour trouvée n’est jamais appliquée sans demander. Réglages possède une section Vérifier les mises à jour avec un bouton « Vérifier les mises à jour » relié à checkForUpdate(). Comme l’API Service Worker n’a pas d’événement « aucune mise à jour trouvée », SettingsView.vue utilise une heuristique par délai : si needRefresh n’a pas basculé ~2 s après la vérification, un message transitoire « Vous êtes à jour » s’affiche pendant ~3 s. Les tests réutilisent le même point d’ancrage de développement, étendu avec mockCheckFn pour simuler registration.update().
Date de dernière vérification : la section affiche aussi la date du dernier clic sur le bouton (« Jamais vérifié manuellement » tant qu’il n’a pas été utilisé), indépendamment du résultat de la vérification. SettingsView.vue stocke l’horodatage sous la clé de réglage last_update_check_at (ISO-8601 UTC, ajout en place — pas de changement de version de schéma) immédiatement après la résolution de checkForUpdate(), et le formate avec formatDateTime. Cela ne reflète que les clics manuels : l’API Service Worker n’expose aucun événement pour les propres vérifications en arrière-plan du navigateur, il n’y a donc rien à observer pour celles-ci.
Fichiers clés : vite.config.js, src/composables/useAppUpdate.js, src/components/AppUpdateDialog.vue, src/App.vue, src/views/SettingsView.vue
3.14 Journalisation de diagnostic
Il arrivait que l’exécution d’une séance s’arrête silencieusement (plus aucun son), avec un indicateur de rechargement à la réouverture du téléphone et une exécution qui repartait de l’étape 0. L’utilisateur verrouille souvent le téléphone volontairement pendant une séance et cela fonctionne parfois quand même, donc le simple verrouillage de l’écran n’est pas la cause — l’onglet verrouillé/en arrière-plan est parfois gelé puis déchargé par l’OS/le navigateur, et au déverrouillage l’onglet se recharge de zéro, perdant tout l’état en mémoire. Il n’y a pas d’accès distant au téléphone d’un utilisateur pour déboguer cela, donc un journal téléchargeable côté client est le principal outil de diagnostic.
Logger : src/utils/logger.js est un singleton module ES simple (pas un composable), afin d’être appelable depuis les repositories, les gestionnaires d’événements, et avant même le montage de Vue. Quatre niveaux — NONE < ERROR < INFO < DEBUG — filtrent les appels de façon synchrone via une variable de module ; le niveau actif est mis en cache depuis settings.log_level (aucun changement de schéma), donc un logger désactivé ne fait aucun accès OPFS.
Stockage et rotation : les fichiers journaux vivent dans un répertoire OPFS logs/ à la racine OPFS, un répertoire frère du VFS SQLite .kaivalya-trainer/ (§2.1) — pas à l’intérieur, pour qu’un export/restauration de BD ne touche jamais aux journaux et que la journalisation continue de fonctionner si c’est la couche BD elle-même qui est en panne. Les lignes sont mises en tampon en mémoire et vidées toutes les 2 s, sur visibilitychange→masqué, et sur pagehide ; logger.error() vide immédiatement. Un fichier tourne vers un nouveau quand sa date de création n’est plus aujourd’hui, ou qu’il dépasse 10 Mo — selon ce qui arrive en premier — et au plus 14 fichiers sont conservés, le plus ancien étant supprimé. Les noms de fichiers portent l’horodatage de rotation pour la lisibilité et l’unicité : log-YYYYMMdd-HHmmss.txt. Comme les noms ont une résolution à la seconde, deux rotations dans la même seconde (atteignable seulement en forçant une taille max minuscule pour tester la rotation) entreraient sinon en collision et fusionneraient silencieusement en un seul fichier ; le logger retient l’horodatage de la dernière rotation et le décale d’une seconde si nécessaire pour garantir des noms distincts.
Contenu journalisé : logger.info(action, data) fixe data.button à 'Automatic' par défaut si omis, donc les lignes INFO distinguent un clic utilisateur (button=Done, button=Continue, …) d’une transition automatique (expiration de minuteur, vérification de sauvegarde, événements de cycle de vie). SessionExecuteView.vue journalise une INFO pour chaque bouton avec le nom du gestionnaire, l’étape courante et l’exercice ; useSessionExecution.js journalise du DEBUG à l’entrée de ses fonctions de transition d’état et de l’INFO sur les transitions automatiques (expiration EMOM/TABATA/AMRAP, _finish) ; useTimer.js journalise un événement INFO timerCatchUp quand un recalcul masqué→visible fait sauter le compte à rebours de plus de 2 secondes — preuve directe que l’onglet a été gelé. Les gestionnaires globaux window.addEventListener('error'/'unhandledrejection') et visibilitychange/pagehide/pageshow (avec le drapeau persisted — false après un déchargement d’onglet prouve le rechargement) sont la principale preuve pour diagnostiquer le schéma gel/déchargement lui-même.
Interface Réglages : une carte « Journaux de diagnostic » (SettingsView.vue) expose le sélecteur de niveau, un résumé nombre de fichiers/taille, une boîte de dialogue de téléchargement (LogDownloadDialog.vue — liste les fichiers avec des cases à cocher et un « tout sélectionner »), et un bouton d’effacement. Télécharger un seul fichier sélectionné le produit tel quel ; en télécharger plusieurs les regroupe en une seule archive kaivalya-trainer-logs-<date>.zip via src/utils/zip.js (un writer ZIP minimal, sans dépendance, méthode STORE) plutôt que de déclencher un clic <a download> par fichier, car les navigateurs limitent ou bloquent silencieusement plusieurs téléchargements automatiques issus d’un seul clic.
Tests : un point d’ancrage réservé au développement window.__loggerTest = { setMaxSize, setMaxFiles, flushNow } (même schéma que les points d’ancrage de test d’useAppUpdate.js) permet à Playwright de forcer la rotation de façon déterministe plutôt que d’attendre de vrais minuteurs ou d’écrire 10 Mo de données.
Fichiers clés : src/utils/logger.js, src/main.js, src/db/bootstrap.js, src/components/LogDownloadDialog.vue, src/utils/zip.js, src/views/SettingsView.vue
3.15 Résilience de session (Wake Lock, audio de maintien + reprise)
Fonctionnalité compagnon du §3.14, répondant au même symptôme signalé. Pour être honnête sur ce qui est garanti ou non : une application web ne peut pas empêcher l’OS de décharger un onglet en arrière-plan sous pression mémoire. Le wake lock et l’audio de maintien suppriment ou atténuent les déclencheurs les plus courants ; l’instantané de reprise est ce qui rend réellement un arrêt récupérable, quelle qu’en soit la cause.
Constats du test terrain (Step 34, analyse de logs du 2026-08-26) : un vrai test sur téléphone a montré que l’audio se déclenchait correctement à l’heure prévue pendant plus de 26 minutes, écran verrouillé — le silence est un symptôme du processus tué, pas un problème de suspension audio. Android a tué l’application à plusieurs reprises dans les 2 à 4 minutes suivant un verrouillage manuel pendant qu’une exécution était en cours (aucun pagehide/freeze/beforeunloadPrompt en fin de fichier de log précédent — le processus meurt sans aucun événement de cycle de vie), alors que la même page masquée a survécu plus d’une heure une fois inactive. L’instantané sessionStorage de l’étape 33 n’a jamais survécu à ces arrêts : sessionStorage est effacé quand Android tue un processus de PWA installée. Par ailleurs, le Screen Wake Lock n’empêche que le verrouillage automatique de l’écran — il ne couvre pas cet utilisateur, qui verrouille toujours manuellement avec le bouton d’alimentation ; le sentinel est libéré dès que la page est masquée et ne peut pas être ré-acquis pendant le verrouillage. wake_lock_enabled reste tel quel (toujours utile pour les utilisateurs en verrouillage automatique) mais ne fait pas partie du correctif de l’étape 34.
Constats du test terrain (révision Step 34, seconde analyse de logs du 2026-08-26) : la première implémentation de l’audio de maintien (WAV de 1s, 8 bits, échantillons tous à zéro) a échoué sur le terrain — les heartbeats se sont arrêtés pendant ~23 minutes écran verrouillé, alors même que l’élément de maintien était « en lecture ». Deux causes indépendantes, une par famille de navigateurs : Firefox (le navigateur de l’utilisateur) ne considère comme média contrôlable que les médias de plus de 3s (media.mediacontrol.eligible.media.duration.s dans Gecko), et c’est cette éligibilité qui fait publier à Firefox sur Android la notification multimédia dont le service de premier plan protège le processus — une boucle de 1s n’est jamais éligible, donc Android a gelé l’application comme d’habitude. Chrome détermine l’audibilité d’un onglet à partir de la puissance du signal décodé (silence ≈ sous −72 dBFS), donc un fichier silencieux n’y obtient pas non plus l’exemption de limitation en arrière-plan. Pire : les échantillons WAV 8 bits sont non signés (silence = 0x80) ; le tampon tout à zéro se décodait en décalage DC pleine échelle joué à volume 1 — inaudible après le filtre DC matériel mais capable de déclencher les limiteurs de protection des haut-parleurs Android, suspect principal d’une régression signalée où les bips Web Audio étaient devenus muets partout (séance et test du son des Paramètres) alors que les annonces vocales (moteur TTS système, sans AudioContext) continuaient de fonctionner.
Wake lock : src/composables/useWakeLock.js encapsule navigator.wakeLock.request('screen'), activable via un réglage wake_lock_enabled désactivé par défaut — l’utilisateur verrouille parfois volontairement le téléphone pendant une séance et cela doit continuer de fonctionner, donc l’application ne doit jamais forcer l’écran à rester allumé. Le navigateur libère le sentinel du wake lock dès que la page est masquée ; le composable garde un drapeau wanted séparé de l’octroi éphémère active, et redemande le verrou sur visibilitychange→visible si l’appelant le veut toujours.
Audio de maintien (Step 34, révisé) : src/composables/useKeepAliveAudio.js construit à l’exécution un WAV quasi silencieux de 10s, mono, 8kHz, 16 bits (aucun fichier commité) — une sinusoïde de 40 Hz à ≈ −54 dBFS (amplitude 64/32767), avec un nombre entier de cycles pour une boucle sans discontinuité — l’encapsule dans une URL de Blob, et le joue en boucle via un élément <audio> pendant toute la durée d’une exécution — volume = 1, jamais muted. Chaque paramètre est déterminant : la durée doit dépasser le minimum de 3s du média contrôlable de Gecko (notification multimédia + service de premier plan sur Firefox/Android — le mécanisme qui garde réellement le processus en vie ; la notification de Chrome demande ≥ 5s) ; le signal ne doit pas être du silence numérique (le contrôle d’audibilité de Chrome mesure la puissance du signal décodé, seuil ≈ −72 dBFS) tout en restant très en dessous du seuil d’audition humaine sur un haut-parleur de téléphone ; et des échantillons 16 bits signés centrés sur 0 évitent le piège du DC en 8 bits non signé décrit dans les constats du test terrain ci-dessus. La cible vérifiée est Firefox pour Android (l’appareil de l’utilisateur — l’apparition de la notification multimédia pendant une séance est le signal de succès observable) ; sur Chrome/Android le même fichier devrait satisfaire le contrôle d’audibilité, mais c’est au mieux et non vérifié sur un vrai appareil. L’élément est possédé par useSessionExecution.js (et non la vue) et toujours actif pendant l’exécution, sans réglage — il est inaudible et ne tourne que pendant que les minuteurs/l’audio tournent de toute façon, donc un interrupteur ajouterait un mode de défaillance sans bénéfice. start() est idempotent (sans effet si déjà en lecture) ; un rejet de play() (ex. lecture automatique bloquée hors d’un contexte de geste utilisateur) est intercepté et journalisé en ERROR (keepAliveFailed) plutôt que de lever une exception, afin qu’un blocage de l’autoplay ne casse jamais l’exécution. C’est une atténuation au mieux, pas une garantie — une page web ne peut toujours pas empêcher l’OS de la tuer en cas de mémoire faible, d’appel entrant ou d’usage de la caméra.
Récupération du maintien en vie (correctifs terrain du 2026-08-31) : le composable sépare désormais l’intention de la lecture réelle, exactement comme useWakeLock sépare wanted de active. isPlaying n’était affecté que par start() et stop() : ce n’était pas une observation mais une affirmation — quand Android mettait l’élément en pause à l’insu de l’application, le drapeau restait à true, et tout un log terrain de lignes heartbeat audioPlaying=true ne prouvait rien. Il suit maintenant les événements play / pause / ended de l’élément.
Une pause inattendue — reçue alors que wanted est vrai — est journalisée (keepAlivePaused) et combattue : play() est retenté avec un délai croissant de 500 ms à 8 s, à nouveau sur visibilitychange → visible, et une dernière fois depuis un écouteur pointerdown à usage unique lorsqu’une tentative programmatique est refusée (seule la règle d’activation utilisateur peut la refuser, et la prochaine touche à l’écran est le seul moment où elle peut aboutir). Une récupération réussie journalise keepAliveRecovered.
La source probable de ces pauses est la voix de l’application elle-même : _startFresh() appelait keepAlive.start() mais ne journalisait qu’après await el.play(), si bien que loadStepState() — la demande de permission de géolocalisation et le premier speechSynthesis.speak() — s’exécutait à l’intérieur de la promesse en attente. Lors des trois démarrages du test terrain, geoStart précédait keepAliveStarted d’environ 270 ms. Sur Android, la synthèse vocale prend le focus audio et le navigateur met les autres médias en pause, entraînant avec eux le service de premier plan derrière la notification média. keepAlive.start() est désormais attendu avant loadStepState() dans _startFresh() et avant _resumeStepState() dans resumeExecution(), ce qui referme cette fenêtre.
Diagnostics des bips (révision Step 34) : _beep() dans useAudioCues.js attend désormais AudioContext.resume() quand le contexte n’est pas en cours d’exécution et journalise beepCtxNotRunning (contexte toujours arrêté après resume — le bip est mis en file, pas entendu) et beepFailed (exception) en ERROR, au lieu d’avaler chaque échec en silence — la régression des bips ci-dessus était invisible dans les logs terrain précisément parce que rien n’était journalisé.
Tirer pour rafraîchir : SessionExecuteView.vue ajoute une classe no-overscroll à <html> au montage (retirée au démontage) qui fixe overscroll-behavior-y: none (src/styles/layout.css), limitée à la route d’exécution.
Garde rechargement/fermeture : un écouteur beforeunload, actif seulement tant que !sessionDone && !loading, complète la confirmation onBeforeRouteLeave déjà existante pour la navigation dans l’application.
Instantané de reprise : useSessionExecution.js écrit un instantané JSON dans localStorage (déplacé depuis sessionStorage à l’étape 34 — sessionStorage ne survit pas à un arrêt de processus Android, exactement la panne reproduite à plusieurs reprises lors du test terrain ; localStorage y survit) sous la clé kaivalya-trainer.execution.<sessionId>, à chaque action qui change l’état (mutation de file/index, chargement des reps/poids/côté, transitions vers l’écran de fin de round et de fin d’AMRAP, et — depuis l’étape 34 — à chaque transition de phase TABATA travail/repos). La clé, la validation de forme et la garde d’ancienneté RESUME_MAX_AGE_MS de 12h existante sont inchangées. La phase du minuteur est désormais conservée pour le TABATA (timerPhase: 'work' | 'rest' | 'amrap' | null) : un arrêt pendant une phase de repos TABATA reprend directement sur l’écran de repos au lieu de rejouer une phase de travail déjà effectuée. Le comportement EMOM/AMRAP est inchangé (simplification documentée depuis l’étape 33) : l’EMOM redémarre sa minute courante à sa durée complète, et l’AMRAP redémarre depuis le dernier temps restant connu (enregistré à chaque tick) arrondi à la minute supérieure. Les écrans d’attente « fin de round » et « temps écoulé AMRAP » (aucun minuteur actif) sont capturés explicitement pour que la reprise sur ces écrans ne redémarre pas un minuteur par erreur.
Au montage de l’écran d’exécution, un instantané pour la même séance de moins de 12 h est proposé via une invite explicite « Reprendre à l’étape N / Recommencer » plutôt qu’une reprise silencieuse — une reprise silencieuse pourrait piéger un utilisateur ayant volontairement voulu recommencer. Reprendre réutilise la ligne execution_log existante (pas de doublon) et relance l’audio de maintien et le heartbeat ; Recommencer efface l’instantané et exécute le chemin de démarrage normal (nouvelle ligne de journal). Un instantané qui échoue à l’analyse ou à une validation de forme basique est traité comme l’absence d’instantané — journalisé en erreur, avec repli sur un démarrage normal plutôt qu’un blocage ou un plantage.
Diagnostics heartbeat (Step 34, étendu le 2026-08-31) : pendant qu’une exécution est active, useSessionExecution.js journalise une ligne INFO heartbeat toutes les 60s : index de l’étape, phase du minuteur, temps restant, document.visibilityState, si l’audio de maintien est en lecture, l’état paused réel de l’élément, navigator.mediaSession.playbackState, le nombre de points enregistrés et l’ancienneté de la dernière position GPS. Les quatre derniers ont été ajoutés après le premier test terrain, où la seule preuve audio du log était un drapeau incapable d’être faux : un heartbeat dit désormais objectivement si la notification média (et le service de premier plan derrière elle) tenait encore, et si les positions arrivaient toujours. logger.js enregistre en outre les événements de cycle de vie freeze/resume et pagehide/pageshow, pour qu’un gel apparaisse directement dans le log au lieu d’être déduit de heartbeats manquants. À environ 60 lignes/heure, c’est négligeable face à la rotation de 10 Mo des logs (§3.16) ; son seul but est que le prochain test terrain montre directement une limitation ou un arrêt dans les logs — un écart entre deux heartbeats de plus de ~65s signifie une limitation, une fin abrupte sans événement de cycle de vie signifie un arrêt. Aucun code d’analyse dédié n’est nécessaire : les horodatages du log se lisent directement.
Tests : l’API Wake Lock est simulée via Object.defineProperty(navigator, 'wakeLock', …) dans un script d’initialisation plutôt que de compter sur de vrais octrois, car les navigateurs headless peuvent ne pas réellement accorder de verrou — la simulation compte de façon fiable les appels de demande/libération dans tous les cas. Le rejet de Audio.play() est testé de la même façon, en substituant HTMLMediaElement.prototype.play dans un script d’initialisation pour renvoyer une promesse rejetée.
Fichiers clés : src/composables/useWakeLock.js, src/composables/useKeepAliveAudio.js, src/composables/useSessionExecution.js, src/views/SessionExecuteView.vue, src/styles/layout.css, src/views/SettingsView.vue
3.16 Mode poche (suivi cardio écran couvert)
Le problème, dit franchement : le suivi cardio avec le téléphone verrouillé ne fonctionne pas sur les navigateurs Android, et aucune page web ne peut le faire fonctionner. Depuis Android 10, le système ne fournit la position à une application détenant la permission « pendant l’utilisation » que tant que cette application a une activité visible, ou un service de premier plan déclaré avec foregroundServiceType="location". Le service de premier plan de Firefox est celui des médias, derrière l’audio de maintien du §3.15, de type mediaPlayback : il garde le processus en vie — battements de cœur, minuteurs et audio survivent tous à un écran verrouillé — mais ne porte aucune capacité de localisation, donc le système cesse silencieusement de livrer les positions à une surveillance pourtant enregistrée. Aucune erreur n’atteint la page.
C’est exactement ce qu’a enregistré la séance terrain du 2026-09-01 : la surveillance est restée enregistrée et muette jusqu’à 1749 s pendant que tout le reste tournait, et 34 des 40 éléments de journal de la séance ne contiennent aucun gps_track. Le bug Mozilla 1482733 (« watchPosition() ne fonctionne pas en arrière-plan sur Android O et suivants ») est ouvert en P2 depuis 2018 ; Fenix ne demande pas ACCESS_BACKGROUND_LOCATION, donc Android ne propose même jamais « Autoriser tout le temps ». Chromium arrête la géolocalisation des documents masqués par conception, changer de navigateur n’y change donc rien, et /e/OS repose sur LineageOS et applique la même règle AOSP. Le réarmement du chien de garde du §3.7 (étape 31h) reste en place parce qu’il est juste et sans coût, et parce que geoRearm reason=stale dans un journal terrain est le marqueur objectif de cette limite — mais il ne peut pas faire apparaître une position que le système refuse d’envoyer.
La conception : le seul levier d’une page web est de rester visible, donc la page devient son propre écran de verrouillage. Le mode poche tient le Screen Wake Lock et recouvre tout d’une surface noire qui absorbe les touchers et n’affiche qu’un texte large et sombre : l’heure (comme un écran de verrouillage, relue toutes les 5 s par la surface elle-même via formatClockTime() de dateFormatter.js), le chrono de l’étape, la distance et l’état du GPS. Le document ne devient jamais masqué : la surveillance GPS, les minuteurs, les signaux sonores, la notification média et les touches du casque gardent tous leur plein service.
- Composable :
src/composables/usePocketMode.jsporte la machine à états et exportePOCKET_IDLE_MS(20 s) etPOCKET_HOLD_MS(3 s), pour que le composant et les tests partagent une seule source de vérité. - L’entrée est à la fois manuelle (un bouton sur la carte cardio) et automatique (après 20 s sans
pointerdown/keydownsur le document), pilotée par un réglagepocket_modedont les valeurs sontoff,manualetauto. Il vautautopar défaut : verrouiller le téléphone perd la séance, donc le défaut prudent est celui qui l’enregistre. - La sortie est un appui long de 3 s avec un anneau de progression, jamais un simple toucher. Une poche produit des touchers courts et mouvants ; un appui immobile de 3 s n’arrive pas souvent par accident, et quand cela arrive le minuteur d’inactivité recouvre l’écran 20 s plus tard.
- L’éligibilité se limite aux étapes cardio, et seulement tant qu’aucun écran n’attend une décision : l’invite de reprise, la carte de dernier round, la carte de fin de temps AMRAP, le détail d’exercice et le résumé de fin découvrent l’écran automatiquement (
leave('step')). - Propriété du wake lock. Le mode poche demande toujours le verrou, indépendamment de
wake_lock_enabled, et ne le libère en sortie que si ce réglage ne le voulait pas — le libérer autrement effacerait l’intention que ce réglage détient et l’écran recommencerait à se verrouiller seul en pleine séance. Il y a exactement une instance deuseWakeLockpar vue, passée au composable : une seconde instance tiendrait son proprewantedet libérerait la sentinelle que la première veut encore. L’entrée saute aussi la demande quand une sentinelle est déjà tenue, caruseWakeLockne mémorise que la dernière et une seconde demande abandonnerait la première. - La disponibilité dépend de
navigator.wakeLock. Sans lui, la surface ne peut pas garder la page visible : le bouton et l’entrée automatique sont masqués et le réglage est désactivé, comme le fait déjà la case du wake lock. - Interrompu par un SMS. La surface est un état réactif en mémoire : elle survit à
visibilitychangeet est toujours là au retour dans l’application ; le wake lock est repris par le gestionnaire de visibilité déjà présent dansuseWakeLock. Les positions manquées pendant l’absence forment un trou dans la trace, que la règle des 30 s d’écart refuse déjà de combler. - Tout recouvrir, au sens propre. Leaflet empile ses calques et ses contrôles de
z-index200 à 1000 et son conteneur ne crée aucun contexte d’empilement, si bien qu’au premier test sur téléphone la carte se dessinait par-dessus l’écran noir — et en portrait la bande de carte tombe exactement là où la surcouche centre son horloge, d’où un temps lisible en paysage seulement. Le conteneur de carte porte désormaisisolation: isolate, ce qui garde cette échelle locale à la carte de séance, et la surcouche passe enz-index: 9000— au-dessus de tout composant de page, sous les boîtes de dialogue modales à 10000, qui doivent rester accessibles. - La carte se fige en dessous. Une carte qui continue de se recentrer derrière un écran noir opaque télécharge quand même des tuiles et se redessine, dépensant l’autonomie que ce mode existe pour préserver.
CardioExecuteCardreçoit une propriétémapFrozenliée àpocketActiveet saute_updateMap()tant qu’elle est vraie — l’enregistrement, la distance, les signaux sonores et l’affichage de la surcouche ne changent pas, seul le dessin attend. La sortie déclenche une mise à jour qui redessine tout le tracé (_syncPolylines()réaffecte la liste complète des points de chaque segment au lieu d’ajouter), déplace le marqueur et recentre sur l’athlète. L’objet carte et ses tuiles restent en place : la restauration ne coûte rien et n’a besoin d’aucun réseau. - Ce qui est délibérément absent : des boutons. Pause, suivant, précédent et arrêt sont tous accessibles depuis la notification média et les touches du casque, qui continuent de fonctionner en dessous.
La carte cardio porte aussi une ligne sous les contrôles GPS indiquant que verrouiller l’écran met le GPS en pause sur Android, pour que la limite soit nommée là où le choix se fait et pas seulement dans ce document.
Tests : tests/test_step37_pocket_mode.py simule l’API Wake Lock comme le §3.15, plus les deux choses dont cette fonctionnalité a besoin : des sentinelles libérables comme un navigateur les libère quand la page est masquée (ce qui donne son sens à l’assertion de reprise du verrou) et un document.visibilityState commutable. page.clock pilote le décompte d’inactivité comme l’appui long. Retirer la prise en charge du wake lock consiste à supprimer l’accesseur de Navigator.prototype, pas à le masquer sur l’instance, car la prise en charge se teste avec 'wakeLock' in navigator.
Fichiers clés : src/composables/usePocketMode.js, src/components/PocketOverlay.vue, src/components/CardioExecuteCard.vue, src/views/SessionExecuteView.vue, src/views/SettingsView.vue, src/utils/cardioFormat.js
4. Routage
Toutes les routes utilisent le mode hash (createWebHashHistory). Le chemin racine / redirige vers /home.
| Route | Vue | Notes |
|---|---|---|
#/home | HomeView | Aperçu de l’activité |
#/exercises | ExerciseListView | Liste + recherche |
#/exercises/new | ExerciseFormView | Mode création |
#/exercises/:id | ExerciseDetailView | Lecture seule |
#/exercises/:id/edit | ExerciseFormView | Mode édition |
#/combos | ComboListView | Liste + recherche |
#/combos/new | ComboFormView | Mode création |
#/combos/:id | ComboDetailView | Lecture seule |
#/combos/:id/edit | ComboFormView | Mode édition |
#/sessions | SessionListView | Liste + recherche |
#/sessions/new | SessionFormView | Mode création |
#/sessions/:id | SessionDetailView | Lecture seule |
#/sessions/:id/edit | SessionFormView | Mode édition |
#/sessions/:id/execute | SessionExecuteView | Mode exécution |
#/collections | CollectionsView | Liste + recherche |
#/collections/new | CollectionFormView | Mode création |
#/collections/:id | CollectionDetailView | Lecture seule |
#/collections/:id/edit | CollectionFormView | Mode édition |
#/stats | StatsView | Statistiques + graphiques |
#/settings | SettingsView | Réglages utilisateur |
navKeyMap dans App.vue fait correspondre les sous-routes (par exemple exercise-detail, exercise-edit) à leur clé de navigation de premier niveau (exercises), pour que l’élément du menu reste mis en évidence lorsque l’utilisateur navigue vers une vue détail/édition.
5. Internationalisation
Bibliothèques : i18next + i18next-vue
Fichiers de locale : src/i18n/locales/en.json (anglais, par défaut), src/i18n/locales/fr.json (français), src/i18n/locales/da.json (danois)
Initialisation : src/i18n/index.js enregistre les trois locales et fixe l’anglais comme langue par défaut. Le composable useTranslation() de i18next-vue est utilisé dans tous les composants — aucun appel direct à i18next dans les templates.
Démarrage : src/main.js appelle i18next.changeLanguage(stored_language) avant le premier rendu, pour que l’interface s’ouvre toujours dans la langue persistée de l’utilisateur.
Changement de langue : le sélecteur de langue des Réglages appelle i18next.changeLanguage() et enregistre la nouvelle valeur dans settings.language. Tous les appels réactifs t() se mettent à jour immédiatement.
Formatage des dates : dateFormatter.js lit i18next.language comme repli, mais accepte un paramètre lang explicite pour permettre un affichage réactif des dates dans les propriétés calculées Vue.
Voir Guides pratiques → Ajouter une nouvelle langue pour ajouter le support d’une langue supplémentaire.
6. Versionnement
Version de l’application
Issue de package.json, injectée par Vite sous la forme __APP_VERSION__ (une constante globale de type chaîne). Disponible au build (nom du cache du SW) et à l’exécution (métadonnées d’export, table settings, noms de fichiers de sauvegarde). Elle est purement informative — elle ne joue aucun rôle dans l’emplacement des données ni dans les décisions de migration.
Nommage du fichier de base de données
La base de données OPFS vit à un emplacement stable, indépendant de la version : dossier .kaivalya-trainer, fichier kaivalya-trainer.db. OPFS est scopé par origine, donc les données survivent automatiquement aux mises à jour de l’application ; les changements de schéma sont gérés par le système de migration au démarrage (§2 Système de migration), pas par un déplacement des données.
Note historique : avant l’étape 16, le dossier s’appelait
.trainus-{appVersion}(sous l’ancien nom de l’application, TrainUs), abandonnant les données à chaque release. Les anciens dossiers versionnés des machines de développement sont simplement laissés à l’abandon (à nettoyer via les outils de développement du navigateur si besoin).
Version de schéma
SCHEMA_VERSION dans src/db/schema.js est un entier stocké dans la table schema_version. Il pilote à la fois la vérification de migration au démarrage et la vérification de compatibilité de Restaurer la base de données.
Règle : SCHEMA_VERSION n’est incrémenté que lorsqu’un changement de schéma est déployé auprès des utilisateurs. Les changements en place avant la première publication (ajouter des colonnes directement dans SCHEMA_SQL) ne nécessitent pas d’augmentation.
Registre des releases
src/db/releases.js exporte un tableau RELEASES — la source de vérité unique pour l’historique du schéma. Chaque entrée déclare { schemaVersion, appVersion, dbBreak, migrationScript }, où migrationScript est le SQL qui amène une base existante de schemaVersion - 1 à schemaVersion (ou null pour une migration no-op quand dbBreak est faux). Chaque augmentation de SCHEMA_VERSION doit s’accompagner d’exactement une entrée RELEASES.
Deux consommateurs partagent ce registre :
getMigrationScripts(from, to)— scripts ordonnés pour la migration au démarrage.checkCompatibility(imported, current)— flux de Restaurer la base de données.
7. Commandes de build
npm install
npm run dev # Serveur de développement Vite sur http://localhost:5173
npm run build # Build de production dans dist/
npm run preview # Prévisualiser la build de production en local
npm run format # Formate tous les fichiers avec Prettier
npm run format:check # Vérification CI (code de sortie non nul en cas de différence)
Les en-têtes COOP/COEP sont injectés automatiquement par vite.config.js pour le serveur de développement. Voir Guides pratiques → Déployer Kaivalya Trainer pour le déploiement en production.
Voir tests/README.md pour la configuration complète des tests et les instructions d’exécution.
8. Annexes
Annexe A — Largeur de contenu limitée sur les vues liste/détail
Sur les grands écrans de bureau (par exemple 1920×1080), les pages de contenu n’occupent pas toute la largeur disponible. Les cartes s’arrêtent à environ 800px.
| Vue | Limite |
|---|---|
| Vues liste (exercices, combos, séances, collections) | 800 px |
| Vues détail | 800 px |
| Vues formulaire | 600 px |
| Vue réglages | 600 px |
La limite est implémentée par vue (pas globalement) afin que chaque écran puisse opter pour une largeur de lecture différente.
Pour ajouter une mise en page multi-colonnes sur grand écran
- Augmentez la limite des vues liste à
1200px(ou supprimez-la) et remplacez la pile verticale par une grille CSS avecgrid-template-columns: repeat(auto-fill, minmax(320px, 1fr)). - Conservez les vues détail et réglages à leurs limites actuelles.
- N’enveloppez que les cartes dans un
<div class="grid-wrapper">interne, pour éviter de redessiner la barre d’action et les messages d’état vide. - Appliquez le même pattern à toutes les vues liste, par cohérence.