trainUs/website/content/developers/technical.fr.md
David 270ff24c60
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Tests (${{ matrix.browser }}) (chromium) (push) Has been cancelled
CI / Tests (${{ matrix.browser }}) (firefox) (push) Has been cancelled
Fix some issue at first load and cache issue
2026-07-26 13:03:09 +02:00

78 KiB
Raw Blame History

title description weight date
Référence Architecture, schéma de base de données, fonctionnalités, routage, i18n, versionnement et commandes de build — la référence technique complète. 3 2026-06-16

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]({{< relref "getting-started.fr.md" >}}) ; pour des tâches concrètes, voir [Guides pratiques]({{< relref "how-to-guides.fr.md" >}}) ; pour le « pourquoi » des choix, voir [Comprendre l'architecture]({{< relref "understanding-the-architecture.fr.md" >}}).

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]({{< relref "understanding-the-architecture.fr.md" >}}) 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
Icônes Bootstrap Icons
Markdown marked + DOMPurify
PWA vite-plugin-pwa + Workbox
Formatage Prettier
Tests Playwright (Python)
Licence GNU 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) :

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 TrainUs]({{< relref "how-to-guides.fr.md" >}}#déployer-trainus) pour la configuration côté serveur, et [Comprendre l'architecture]({{< relref "understanding-the-architecture.fr.md" >}}) 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]({{< relref "understanding-the-architecture.fr.md" >}}) 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.

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)
combo Définitions de combos (title UNIQUE)
combo_exercise Jonction combo ↔ exercice (position, reps, poids)
session Définitions de séances (title UNIQUE)
session_item Jonction séance ↔ élément (type, position, reps, poids)
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é, …)
suffix Registre 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]({{< relref "how-to-guides.fr.md" >}}#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 (0100 %) + 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 (1365, 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 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ément Licence Source
Icône de l'app (Mighty Force) CC BY 3.0 game-icons.net, par Delapouite
Bootstrap Icons MIT icons.getbootstrap.com
Bibliothèques tierces Vue, Vue Router, Vite, SQLite WASM, Chart.js, vue-chartjs, i18next, i18next-vue, Prettier, vite-plugin-pwa, marked, DOMPurify
Outils de test Playwright, 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 :

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)
Asymétrique Booléen ; révèle la sous-option Alternance
Alternance Bouton radio : alterner par répétition ou par série
Répétitions par défaut ≥ 0
Poids par défaut ≥ 0
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).

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

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.

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

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)

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

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.

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

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 :

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 (0100) et converti en multiplicateur 0.01.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é

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.

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é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 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é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_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_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

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_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 (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 :

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

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]({{< relref "how-to-guides.fr.md" >}}#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]({{< relref "how-to-guides.fr.md" >}}#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.

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

  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.