---
title: 'Référence'
description: 'Architecture, schéma de base de données, fonctionnalités, routage, i18n, versionnement et commandes de build — la référence technique complète.'
weight: 3
date: 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](https://github.com/AlDanial/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

```mermaid
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).

```mermaid
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 (0–100 %) + bouton Tester                           | `settings.audio_volume`         |
| Télécharger la DB                  | Bouton → télécharge un `.sqlite3`                           | —                               |
| Restaurer la DB                    | Sélecteur de fichier → pipeline de validation               | —                               |
| Initialiser la DB                  | Bouton + case à cocher obligatoire                          | —                               |
| Intervalle de rappel de sauvegarde | Champ numérique (1–365, défaut 7)                           | `settings.backup_reminder_days` |
| Exporter des données               | Bouton → boîte de filtre par entité                         | —                               |
| Importer des données               | Bouton → sélecteur de fichier                               | —                               |
| Gestion des suffixes               | Liste des suffixes enregistrés                              | table `suffix`                  |
| Crédits                            | Bouton → fenêtre modale (attributions icônes/bibliothèques) | —                               |
| À propos                           | Bouton → fenêtre modale (version, auteur)                   | —                               |

Le volume sonore est détaillé en [§ 3.7 Signaux sonores](#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 :

```mermaid
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](https://game-icons.net), par Delapouite                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Bootstrap Icons               | MIT       | [icons.getbootstrap.com](https://icons.getbootstrap.com)                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Bibliothèques tierces         | —         | [Vue](https://vuejs.org), [Vue Router](https://router.vuejs.org), [Vite](https://vitejs.dev), [SQLite WASM](https://sqlite.org/wasm/), [Chart.js](https://www.chartjs.org), [vue-chartjs](https://vue-chartjs.org), [i18next](https://www.i18next.com), [i18next-vue](https://i18next.github.io/i18next-vue/), [Prettier](https://prettier.io), [vite-plugin-pwa](https://vite-pwa-org.netlify.app), [marked](https://marked.js.org), [DOMPurify](https://github.com/cure53/DOMPurify) |
| Outils de test                | —         | [Playwright](https://playwright.dev/python/), [pytest](https://pytest.org)                                                                                                                                                                                                                                                                                                                                                                                                             |

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()`

```js
{
  ;(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` :

```sql
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 **combo** → `totalRounds × 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` (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é

| 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

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

```mermaid
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 :

```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 (`en` → `en-US`, `fr` → `fr-FR`, `da` → `da-DK`, la même que `useSpeech.js`) et jour/mois sur 2 chiffres, pour une sortie déterministe par langue quelle que soit la locale du système :

| Fonction                      | Anglais                 | Français          | Danois            |
| ----------------------------- | ----------------------- | ----------------- | ----------------- |
| `formatDate(date, lang?)`     | `MM/DD/YYYY`            | `DD/MM/YYYY`      | `DD.MM.YYYY`      |
| `formatDateTime(date, lang?)` | date + `hh:mm:ss AM/PM` | date + `HH:MM:SS` | date + `HH.MM.SS` |

Le paramètre optionnel `lang` permet aux propriétés calculées (`computed`) de Vue de passer la ref réactive de langue, pour que l'affichage des dates se mette à jour immédiatement lors d'un changement de langue.

**Fichiers clés :** `src/composables/useBackupStatus.js`, `src/components/BackupReminder.vue`, `src/utils/backupFilename.js`, `src/utils/dateFormatter.js`

---

### 3.11 PWA

**Plugin :** `vite-plugin-pwa` + Workbox

#### Service worker

| Configuration                   | Valeur       | Justification                                                 |
| ------------------------------- | ------------ | ------------------------------------------------------------- |
| `registerType`                  | `autoUpdate` | Active silencieusement le nouveau SW à la navigation suivante |
| `maximumFileSizeToCacheInBytes` | 10 Mo        | Couvre `sqlite3.wasm` (~8 Mo)                                 |
| `manifest`                      | `false`      | Conserve le `public/manifest.json` écrit à la main            |

Workbox gère la précache, basée sur le hash du contenu, de toute la coquille de l'application au moment de l'installation, ce qui permet un support hors ligne complet. Le binaire WASM est inclus dans le manifeste de précache grâce à la limite de 10 Mo.

#### Stockage persistant

Au démarrage, `main.js` appelle `navigator.storage.persist()` une seule fois, sans attendre le résultat. Sans cet appel, tout le stockage du site est « au mieux » (best-effort) : le navigateur peut évincer le Cache Storage (la précache Workbox, y compris la police d'icônes) et la base SQLite sur OPFS après une longue période d'inactivité ou en cas de pression sur le disque. Une demande accordée exempte l'origine de l'éviction automatique. Chromium décide silencieusement selon des heuristiques d'engagement ; Firefox peut afficher une demande de permission ; un refus (ou un navigateur sans cette API) ne bloque jamais le démarrage — le résultat est seulement journalisé.

#### Manifeste de l'application

`public/manifest.json` déclare `name`, `short_name`, `display: standalone`, `theme_color`, `background_color`, et deux icônes (192×192 et 512×512) avec les usages `maskable` et `any`.

#### Invite d'installation

`useInstallPrompt.js` capture l'événement `beforeinstallprompt` au chargement du module (ref singleton `canInstall`). `InstallPrompt.vue` téléporte un bouton d'icône de téléchargement dans la barre d'action quand `canInstall` est vrai. L'invite peut être ignorée pour la session (pas besoin de persistance).

#### Accessibilité

Ajouts ARIA appliqués dans toute l'application :

- `aria-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

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