Cette page explique pourquoi TrainUs est construit ainsi. Pour les faits — schéma, routes, champs exacts — voir la Référence.

Pourquoi pas de serveur ?

Beaucoup d’applications de fitness fonctionnent avec un compte et un serveur central. TrainUs fait le choix inverse : c’est une Progressive Web Application qui tourne entièrement dans le navigateur, avec toutes les données stockées localement via SQLite WASM et OPFS. Il n’y a aucun serveur à exploiter, aucun système de comptes à concevoir et sécuriser, et l’application continue de fonctionner — intégralement — sans aucune connexion réseau.

La contrepartie est la même que celle décrite côté utilisateur dans Comprendre TrainUs : pas de synchronisation automatique entre appareils, pas de sauvegarde dans le cloud. Pour un contributeur, la conséquence pratique est qu’il n’y a aucune couche API à concevoir ni à maintenir — chaque fonctionnalité est, par construction, une lecture/écriture locale contre les repositories de src/db/repositories/.

Pourquoi SQLite tourne dans un Web Worker

Les API d’accès synchrone aux fichiers d’OPFS (FileSystemSyncAccessHandle) ne sont disponibles que dans un Worker — elles n’existent pas sur le thread principal. C’est donc worker.js qui possède la connexion SQLite proprement dite, et database.js, sur le thread principal, communique avec lui exclusivement via postMessage(), enveloppé dans des Promises identifiées par un ID de réponse (voir le diagramme dans la Référence §1).

Cela signifie aussi que chaque appel à un repository est intrinsèquement asynchrone, même pour une lecture triviale — il n’y a pas d’échappatoire synchrone du type « interroger directement la base ». Les composables existent précisément pour absorber cette asynchronicité derrière une interface réactive en ref, afin que chaque composant n’ait pas à réinventer sa propre gestion du chargement et des erreurs.

Pourquoi le VFS opfs-sahpool — et pas de repli

Parmi les options de VFS OPFS proposées par SQLite WASM, opfs-sahpool a été choisi parce qu’il offre des entrées/sorties synchrones sans nécessiter de SharedArrayBuffer, ce qui veut dire qu’il n’a pas non plus besoin des en-têtes COOP/COEP (contrairement à l’autre VFS OPFS, non-SAH). Le serveur de développement les pose tout de même, par précaution — voir Guides pratiques → Déployer TrainUs pour les en-têtes eux-mêmes.

Il n’y a délibérément aucun repli en mémoire quand OPFS n’est pas disponible (navigateur ancien, navigation privée). Un repli silencieux donnerait l’impression que l’application enregistre bien les données d’entraînement alors que ce n’est pas le cas — une base vide à chaque rechargement, sans aucun avertissement. Une erreur claire en plein écran a été jugée préférable à ce genre de perte de données silencieuse.

Pourquoi le système de migration est construit ainsi

Le flux de migration au démarrage (machine à états complète dans la Référence §2) a quelques propriétés qui ne sont pas évidentes à la seule lecture du diagramme de flux :

  • L’utilisateur doit télécharger une sauvegarde avant même que le bouton Mettre à jour soit activé. Les migrations de schéma exécutent du SQL sans surveillance sur de vraies données utilisateur ; si un script de migration a un bug, le filet de sécurité doit déjà être entre les mains de l’utilisateur avant que l’étape risquée ne s’exécute, pas proposé après coup.
  • Chaque migration s’exécute dans une transaction avec son estampillage schema_version. Un échec revient à un état précédent parfaitement cohérent — la base n’est jamais laissée à moitié migrée. Combiné à la sauvegarde forcée, une migration échouée est récupérable de deux façons : la base fonctionne toujours à l’ancienne version, et l’utilisateur dispose de toute façon d’un export.
  • Une base de données plus récente que l’application en cours d’exécution (dbVersion > SCHEMA_VERSION) échoue de façon sécurisée avec une erreur bloquante plutôt que de tenter quoi que ce soit. Cela couvre le cas de quelqu’un revenant à une version plus ancienne de l’application alors que les données ont déjà été migrées en avant — deviner comment gérer ce cas serait bien plus risqué que de simplement s’arrêter.
  • Le même registre RELEASES et les mêmes scripts migrationScript pilotent à la fois la migration au démarrage et l’adaptation de Restaurer la base de données. La restauration devait déjà résoudre « amener une sauvegarde plus ancienne au schéma actuel » ; réutiliser ce mécanisme pour le chemin de mise à jour courant a évité de construire (et de maintenir) deux moteurs de migration séparés.

C’est aussi pourquoi les augmentations de SCHEMA_VERSION sont traitées comme une décision délibérée, à valider avant d’agir (voir Guides pratiques → Ajouter une migration de base de données) plutôt que comme quelque chose à utiliser à la légère : chaque augmentation engage un script de migration testé et une entrée RELEASES permanente, pour toujours.

Quelques décisions de conception notables

Une boîte de dialogue de sauvegarde plutôt que le sélecteur natif

Chaque enregistrement sur disque (exports JSON, sauvegarde SQLite, export HTML) passe par une SaveFileDialog.vue personnalisée qui demande un nom de fichier, plutôt que par l’API native showSaveFilePicker() du navigateur. L’API native a été rejetée précisément parce qu’elle n’est pas supportée par Firefox, qui est le navigateur de test principal du projet — voir la Référence §3.12 pour la façon dont la boîte de dialogue est branchée sur les trois points d’entrée de téléchargement existants.

Journaux d’exécution partiels en cas de sortie anticipée

Quitter une séance en cours en sauvegarde les étapes déjà complétées, avec finished_at = null pour marquer une exécution partielle, plutôt que d’abandonner toute la séance ou de forcer l’utilisateur à la terminer. Les séances d’entraînement sont interrompues dans la vraie vie (coup de téléphone, matériel pris par quelqu’un d’autre, temps écoulé) — perdre un échauffement déjà enregistré parce que le reste n’a pas eu lieu rendrait les statistiques moins fiables, pas plus.

Deux stratégies différentes pour les exercices asymétriques

Les exercices asymétriques permettent d’alterner le côté soit après chaque répétition (alternate: true), soit après une série complète (alternate: false) — voir Référence §3.7. Les deux sont des pratiques d’entraînement réelles (fentes alternées vs. rangées un bras à la fois), donc le choix a été laissé en réglage par exercice plutôt que d’en imposer un seul à tout le monde.

Pourquoi EMOM, AMRAP et TABATA calculent chacun les tours différemment

Les trois types de combo minutés réutilisent le même champ repetitions d’un élément de séance avec des significations différentes (tours complets pour NONE/TABATA, durée totale en minutes pour EMOM/AMRAP — voir Référence §3.7) plutôt que d’ajouter un champ séparé par type. Cela garde le modèle de données et le formulaire uniformes entre les types de combo, au prix d’un libellé dépendant du type dans l’interface (« Répétitions » vs. « Durée (min) ») — un compromis qui en vaut la peine puisqu’il évite trois jeux de champs quasi identiques en parallèle.

Pourquoi la largeur de contenu est volontairement limitée

Les vues liste, détail, formulaire et réglages limitent toutes la largeur de leur contenu bien en deçà de la largeur de l’écran sur les grands écrans de bureau (voir Référence, Annexe A) — c’est un choix de lisibilité, pas un oubli. Une carte de séance ou un formulaire étiré sur un écran de 1920px forcerait l’œil à parcourir plus de distance que nécessaire pour ce qui est, fondamentalement, un contenu de liste assez court. La limite est appliquée par vue plutôt que globalement, pour qu’une future mise en page multi-colonnes (voir la recette dans cette même annexe) puisse être introduite progressivement, vue par vue, sans toucher aux autres.