Cette page décrit chaque partie de TrainUs 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

TrainUs 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

CoucheTechnologie
Framework UIVue 3 (Composition API)
BuildVite
RoutageVue Router (mode hash)
i18ni18next + i18next-vue
Base de donnéesSQLite WASM + OPFS
GraphiquesChart.js 4 + vue-chartjs 5
IcônesBootstrap Icons
Markdownmarked + DOMPurify
PWAvite-plugin-pwa + Workbox
FormatagePrettier
TestsPlaywright (Python)
LicenceGNU AGPL v3

Structure du projet

trainUs/
├── 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) :

ZoneFichiersLignes de codeLangages principaux
src/ (application)8113 973Vue SFC, JavaScript, CSS
tests/ (Playwright)377 371Python
website/ (site de docs)494 732Markdown, CSS, HTML
docs/41 500Markdown

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 TrainUs 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

SeuilMise en page
> 700 pxMenu latéral fixe (liens avec libellés)
≤ 700 pxBarre de navigation en bas (icônes + libellés)
≤ 400 pxNavigation 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 1.

TableRôle
schema_versionSuivi des migrations (version + applied_at)
settingsConfiguration clé-valeur (user_uuid, nom, thème, …)
exerciseDéfinitions d’exercices (title UNIQUE)
comboDéfinitions de combos (title UNIQUE)
combo_exerciseJonction combo ↔ exercice (position, reps, poids)
sessionDéfinitions de séances (title UNIQUE)
session_itemJonction séance ↔ élément (type, position, reps, poids)
collectionCollections (label UNIQUE)
collection_sessionJonction collection ↔ séance (position)
execution_logJournaux d’exécution (started_at, finished_at)
execution_log_itemRésultats d’exécution par exercice (reps, poids, côté, …)
suffixRegistre des utilisateurs externes, pour désambiguïser l’import

Diagramme entité-relation

erDiagram SETTINGS { text key PK text value } EXERCISE { text id PK text title UK text description boolean asymmetric boolean alternate text image_urls text video_urls integer default_reps real default_weight text created_by } COMBO { text id PK text title UK text description text type text timer_config text created_by } COMBO_EXERCISE { text combo_id FK text exercise_id FK integer position integer default_reps real default_weight } SESSION { text id PK text title UK text description text created_by } SESSION_ITEM { text session_id FK text item_id text item_type integer position integer repetitions real weight } COLLECTION { text id PK text label UK text created_by } COLLECTION_SESSION { text collection_id FK text session_id FK integer position } EXECUTION_LOG { text id PK text session_id FK datetime started_at datetime finished_at } EXECUTION_LOG_ITEM { text id PK text log_id FK text exercise_id FK text combo_id integer round_number integer reps_done real weight_used text side boolean completed datetime timestamp } SUFFIX { text user_uuid PK text user_name text suffix UK } COMBO ||--|{ COMBO_EXERCISE : contains EXERCISE ||--o{ COMBO_EXERCISE : "used in" SESSION ||--|{ SESSION_ITEM : contains COLLECTION ||--|{ COLLECTION_SESSION : contains SESSION ||--o{ COLLECTION_SESSION : "used in" SESSION ||--o{ EXECUTION_LOG : "executed as" EXECUTION_LOG ||--|{ EXECUTION_LOG_ITEM : records EXERCISE ||--o{ EXECUTION_LOG_ITEM : tracks

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 titres
  • execution_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)
  • 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écute SCHEMA_SQL et estampille SCHEMA_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 envoie runMigrations au 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).

flowchart TD A[Démarrage app : db.init] --> B[Worker : ouvre le dossier OPFS pool .trainus, ouvre trainus.db] B --> C{Table schema_version\nexiste avec des lignes ?} C -- non : base neuve --> D[Exécute SCHEMA_SQL\nINSERT schema_version = SCHEMA_VERSION] C -- oui --> E{dbVersion vs SCHEMA_VERSION} E -- égal --> F[À jour — continue] E -- "dbVersion > app" --> G[Erreur bloquante\nBase intacte] E -- "dbVersion < app" --> H[Écran de migration bloquant\nBase intacte, app en pause] H --> H2[Utilisateur clique Télécharger la sauvegarde\nexportDatabase + formatBackupFilename\nmise à jour de last_backup_at] H2 --> H3[Bouton Mettre à jour activé\nutilisateur clique Mettre à jour] H3 --> I[Pour chaque release N de dbVersion+1 à SCHEMA_VERSION :\nBEGIN ; migrationScript ; INSERT schema_version N ; COMMIT] I -- erreur --> J[ROLLBACK\nErreur bloquante\nBase toujours à la version précédente + utilisateur a la sauvegarde] I -- ok --> F D --> K[_initDefaults : lignes settings] F --> K K --> L[Application prête]

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.

ComposableEnveloppe
useExercisesexerciseRepository
useComboscomboRepository
useSessionssessionRepository
useCollectionscollectionRepository
useExecutionLogsexecutionLogRepository
useSettingssettingsRepository
useStatsPlusieurs repositories (agrégats en lecture seule)
useSessionExecutionexecutionLogRepository + logique de file
useJsonExportTous les repositories + jsonEnvelope.js
useJsonImportTous les repositories + suffixRepository
useBackupStatussettingsRepository
useInstallPromptÉvénement beforeinstallprompt (API navigateur)
useHtmlExportRendu 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églageContrôleClé de persistance
Langue<select> (English / Français / Dansk)settings.language
Nom d’utilisateurChamp texte (requis, non vide)settings.user_name
Identifiant utilisateurAffichage UUID en lecture seulesettings.user_uuid
Thème<select> (Clair / Sombre)settings.theme
Volume sonoreCurseur (0–100 %) + bouton Testersettings.audio_volume
Télécharger la DBBouton → télécharge un .sqlite3
Restaurer la DBSélecteur de fichier → pipeline de validation
Initialiser la DBBouton + case à cocher obligatoire
Intervalle de rappel de sauvegardeChamp numérique (1–365, défaut 7)settings.backup_reminder_days
Exporter des donnéesBouton → boîte de filtre par entité
Importer des donnéesBouton → sélecteur de fichier
Gestion des suffixesListe des suffixes enregistréstable suffix
CréditsBouton → fenêtre modale (attributions icônes/bibliothèques)
À proposBouton → 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 trainus-{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 :

flowchart TD A[Utilisateur clique Restaurer] --> B[Sélecteur de fichier] B --> C[Boîte de confirmation] C -->|Annuler| Z[Abandon] C -->|OK| D[Affiche le spinner] D --> E[Désérialise dans une DB :memory:] E -->|Fichier invalide| F[Erreur : INVALID_FILE] E -->|Valide| G[Vérifie les tables requises] G -->|Manquantes| H[Erreur : INCOMPLETE_DB] G -->|Présentes| I[Lit les versions de schéma] I --> J[Vérifie la compatibilité] J -->|Incompatible| K[Erreur : INCOMPATIBLE_VERSION] J -->|Compatible| L{Adaptation nécessaire ?} L -->|Oui| M[Exécute les scripts d'adaptation] M -->|Échec| N[Erreur : ADAPT_FAILED] M -->|OK| O[Copie table par table dans une transaction] L -->|Non| O O -->|Échec| P[Erreur : RESTORE_FAILED] O -->|OK| Q[Carte récapitulative de succès]

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 et bibliothèques tierces :

ÉlémentLicenceSource
Icône de l’app (Mighty Force)CC BY 3.0game-icons.net, par Delapouite
Bootstrap IconsMITicons.getbootstrap.com
Bibliothèques tiercesVue, Vue Router, Vite, SQLite WASM, Chart.js, vue-chartjs, i18next, i18next-vue, Prettier, vite-plugin-pwa, marked, DOMPurify
Outils de testPlaywright, pytest

Les noms et liens des icônes/bibliothèques sont écrits en dur dans le composant ; le texte d’attribution provient de l’espace de noms i18n credits.*.

Composant : src/components/CreditsDialog.vue

Fenêtre « À propos »

Une fenêtre modale ouverte depuis la carte À propos de TrainUs en bas de la page Réglages. Elle affiche :

ChampSource
Version de l’application__APP_VERSION__ (variable globale Vite issue de package.json)
Version de la base de donnéesSCHEMA_VERSION (depuis src/db/schema.js)
Créé parTexte fixe : David Florance
DépôtLien provisoire (href="#")
Site webLien provisoire (href="#")
Voir la licenceBouton 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

ChampValidation
TitreRequis, UNIQUE (au niveau base)
DescriptionOptionnelle ; rendue en Markdown dans la vue détail (marked + DOMPurify)
AsymétriqueBooléen ; révèle la sous-option Alternance
AlternanceBouton radio : alterner par répétition ou par série
Répétitions par défaut≥ 0
Poids par défaut≥ 0
URLs d’imagesChaque URL doit être une URL HTTP(S) valide
URLs de vidéosChaque 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).

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 @error masque les images cassées.
  • YouTube : les ID de vidéo sont extraits des formats youtube.com/watch?v= et youtu.be/, et intégrés via youtube-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

ChampNotes
TitreRequis, UNIQUE
DescriptionOptionnelle ; rendue en Markdown dans la vue détail
TypeNONE / EMOM / AMRAP / TABATA
Configuration du minuteurDurée (min) pour EMOM/AMRAP ; nombre de tours pour TABATA ; stocké en JSON
ExercicesListe 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.

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.

É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

ChampNotes
TitreRequis, UNIQUE
DescriptionOptionnelle ; rendue en Markdown dans la vue détail
ÉlémentsListe ordonnée ; chaque élément a des répétitions (≥ 1) et un poids (≥ 0)

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.

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

ChampNotes
LibelléRequis, UNIQUE
SéancesListe 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.

WidgetSource de données
Séances des 7 derniers joursexecutionLogRepository.getCountSince(isoDate)
Séances des 30 derniers joursexecutionLogRepository.getCountSince(isoDate)
Les 4 séances les plus récentesexecutionLogRepository.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.

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 :

  1. Récupère /icon.svg et l’encode en URI data: base64 (TextEncoder + btoa).
  2. 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.
  3. Retire les boutons lecture, les liens de navigation et les sélecteurs de date du résultat (export en lecture seule).
  4. Télécharge le fichier sous le nom trainus-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 combototalRounds × M étapes, où totalRounds dépend du type de combo :
Type de comboSignification de repetitionsFormule de totalRounds
NONE/TABATANombre de tours completsrepetitions
EMOMDurée totale (min)floor(repetitions / M) — chaque exercice = un créneau d'1 min
AMRAPDuré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.

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 :

  1. Récupère les étapes complétées pour le dernier tour en cours (répétitions/poids réellement utilisés)
  2. Insère un nouveau lot d’étapes à currentIndex + 1 dans la file
  3. Marque isFinalComboStep = true sur la dernière nouvelle étape
  4. Avance currentIndex jusqu’à la première nouvelle étape

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 :

  1. Déplie un panneau de détail intégré (description rendue en Markdown, images, liens vidéo).
  2. Met en pause le chronomètre actif — mais seulement s’il tournait au moment du clic (le drapeau detailPausedTimer le 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 :

FonctionFréquenceDuréeRôle
playBip(volume)440 Hz0.1 sTic discret — décompte, mi-parcours
playBIP(volume)880 Hz0.3 sSignal 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 trois fonctions privées :

  • _emomSounds(remaining) — déclenche playBip à r = 30, 2, 1 (EMOM uniquement).
  • _amrapLastMinuteSounds(remaining) — déclenche playBip à r = 30, 2, 1 (dernière minute de l’AMRAP).
  • _tabataCountdown(remaining, totalTime) — déclenche playBip à floor(totalTime / 2), r = 2, et r = 1.

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.

Fichiers clés : src/views/SessionExecuteView.vue, src/composables/useSessionExecution.js, src/composables/useAudioCues.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é

CarteRequête
SéancesNombre de lignes execution_log sur la période
Exercices réalisésNombre de lignes execution_log_item (complétées) sur la période
Série de joursJours 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

GraphiqueTypeMétrique
FréquenceBarresSéances par semaine ou par mois (au choix)
VolumeBarresSUM(reps_done × weight_used) par exécution de séance
ProgressionLigneRé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.

Date de création de la base

Affichée en haut sous la forme « Vous utilisez TrainUs 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/composables/useStats.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": "trainus",
    "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éePortée
Vue liste Exercice / Combo / Séance / CollectionTous 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 / CollectionUn seul élément
Réglages → Exporter des donnéesN’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 trainus-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éeNotes
Vue liste Exercice / Combo / Séance / CollectionImport par entité, depuis la barre d’action
Réglages → Importer des donnéesEnveloppe 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énarioComportement
Même utilisateur (created_by = user_uuid local), pas de correspondance d’ID/titreInsertion avec l’ID d’origine
Même utilisateur, même ID existantInvite : Remplacer / Ignorer (avec le lot Remplacer tout / Ignorer tout)
Même utilisateur, ID différent mais même titre qu’une ligne existanteMê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 inconnuDemande un suffixe → enregistré dans la table suffix
Utilisateur différent, même ID + même created_byMise à jour en place (pas de nouvel ID)
Utilisateur différent, même ID + created_by différentGénère un nouvel UUID
Utilisateur différent, titre suffixé en conflit avec une ligne existanteMê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_id
  • session_item.item_id (pour les exercices et les combos)
  • collection_session.session_id
  • execution_log.session_id
  • execution_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_uuidNon — toujours préservée
app_versionNon — toujours préservée
db_created_atNon — toujours préservée
last_backup_atNon — toujours préservée
user_name, language, theme, backup_reminder_daysOui

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

stateDiagram-v2 [*] --> Loading: Fichier sélectionné Loading --> SuffixPrompt: Utilisateur externe détecté Loading --> Preview: Même utilisateur SuffixPrompt --> Preview: Suffixe saisi Preview --> Collisions: Conflits trouvés Preview --> Applying: Aucun conflit Collisions --> Applying: Tous résolus Applying --> Report: Terminé Report --> [*]: Fermé

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

trainus-{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_atRéglée au premier lancementdatabase._initDefaults()
last_backup_atAbsenteSuccè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 (enen-US, frfr-FR, dada-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 :

FonctionAnglaisFrançaisDanois
formatDate(date, lang?)MM/DD/YYYYDD/MM/YYYYDD.MM.YYYY
formatDateTime(date, lang?)date + hh:mm:ss AM/PMdate + HH:MM:SSdate + 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

ConfigurationValeurJustification
registerTypeautoUpdateActive silencieusement le nouveau SW à la navigation suivante
maximumFileSizeToCacheInBytes10 MoCouvre sqlite3.wasm (~8 Mo)
manifestfalseConserve 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.

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-label sur tous les boutons icône-seule de la barre d’action, sur toutes les vues
  • role="progressbar" + aria-valuenow/min/max sur la barre de progression d’exécution
  • role="alert" + aria-describedby sur les messages d’erreur de formulaire
  • required / aria-required sur les champs requis
  • role="dialog" + aria-modal + aria-labelledby + focus au montage + touche Échap sur ModalDialog

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 de useRegisterSW, vraie quand un nouveau SW est installé et en attente
  • applyUpdate() — active updating, puis appelle updateServiceWorker(true) ; la page se recharge quand le nouveau SW prend le contrôle, donc updating n’est jamais réinitialisée
  • dismiss() — rejet valable pour la session uniquement (ref simple, même pattern que InstallPrompt) ; la boîte de dialogue réapparaît au prochain chargement de l’application si la mise à jour est toujours en attente
  • checkForUpdate() / 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 :

  1. Choix — titre, court texte, boutons Mettre à jour / Plus tard.
  2. Mise à jour en cours — spinner + texte « Mise à jour en cours… », non fermable : ModalDialog reçoit aucun titre (ce qui supprime le bouton de fermeture de l’en-tête) et l’événement close est ignoré tant que updating est 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


4. Routage

Toutes les routes utilisent le mode hash (createWebHashHistory). Le chemin racine / redirige vers /home.

RouteVueNotes
#/homeHomeViewAperçu de l’activité
#/exercisesExerciseListViewListe + recherche
#/exercises/newExerciseFormViewMode création
#/exercises/:idExerciseDetailViewLecture seule
#/exercises/:id/editExerciseFormViewMode édition
#/combosComboListViewListe + recherche
#/combos/newComboFormViewMode création
#/combos/:idComboDetailViewLecture seule
#/combos/:id/editComboFormViewMode édition
#/sessionsSessionListViewListe + recherche
#/sessions/newSessionFormViewMode création
#/sessions/:idSessionDetailViewLecture seule
#/sessions/:id/editSessionFormViewMode édition
#/sessions/:id/executeSessionExecuteViewMode exécution
#/collectionsCollectionsViewListe + recherche
#/collections/newCollectionFormViewMode création
#/collections/:idCollectionDetailViewLecture seule
#/collections/:id/editCollectionFormViewMode édition
#/statsStatsViewStatistiques + graphiques
#/settingsSettingsViewRé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 .trainus, fichier trainus.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}, 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 TrainUs 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.

VueLimite
Vues liste (exercices, combos, séances, collections)800 px
Vues détail800 px
Vues formulaire600 px
Vue réglages600 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

  1. Augmentez la limite des vues liste à 1200px (ou supprimez-la) et remplacez la pile verticale par une grille CSS avec grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)).
  2. Conservez les vues détail et réglages à leurs limites actuelles.
  3. 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.
  4. Appliquez le même pattern à toutes les vues liste, par cohérence.