diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index ce793f5..3346b57 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -7,3 +7,5 @@ - [gametime-visual-identity](gametime-visual-identity.md) — memory note gametime-visual-identity - [gametime-ux-execution-nav-and-program-simplification](gametime-ux-execution-nav-and-program-simplification.md) — memory note gametime-ux-execution-nav-and-program-simplification - [gametime-architecture-set-editing](gametime-architecture-set-editing.md) — memory note gametime-architecture-set-editing +- [gametime-ux-score-chrono](gametime-ux-score-chrono.md) — memory note gametime-ux-score-chrono +- [gametime-architecture-score-chrono](gametime-architecture-score-chrono.md) — memory note gametime-architecture-score-chrono diff --git a/.ideai/memory/gametime-architecture-score-chrono.md b/.ideai/memory/gametime-architecture-score-chrono.md new file mode 100644 index 0000000..756a6ed --- /dev/null +++ b/.ideai/memory/gametime-architecture-score-chrono.md @@ -0,0 +1,29 @@ +--- +name: gametime-architecture-score-chrono +description: memory note gametime-architecture-score-chrono +metadata: + type: project +--- +# GameTime — Cadrage Architect : score chronométré (ticket #18, 2026-07-18) + +## Stockage validé +- `actualScoreTimeMs` (nullable) sur `ActiveSetResult` et `WorkoutHistorySetResult`, distinct de `actualScore`. +- `targetScoreTimeMs` sur `ProgramExercise`, `targetScoreTimeMsOverride` sur `WorkoutTemplateExerciseOverride`, distincts de `targetScore`/`targetScoreOverride`. +- Invariant : `manual` utilise `actualScore/targetScore` ; `stopwatch` utilise `actualScoreTimeMs/targetScoreTimeMs` ; jamais les deux familles remplies simultanément. `skipped` implique aucune valeur `actual*` (y compris `actualScoreTimeMs`). + +## État transitoire du chrono +Nouvelle table Drift `ActiveScoreStopwatchState`, sur le même principe que `ActiveRestState` (horodatages persistés, pas de compteur mémoire) : +- clé logique : `activeWorkoutSessionId + programIndex + exerciseIndex + setIndex` +- `status` : `running | stopped` +- `startedAt`, `accumulatedMs`, `stoppedAt?` +- Absence de ligne = chrono non démarré. +- À la validation de la série, copier la durée finale vers `ActiveSetResult.actualScoreTimeMs`. +- Pause de séance : si `running`, figer `accumulatedMs` ; reprise explicite après resume (pas de décompte pendant la pause). +- Terminer la série avec chrono `running` → auto-stop puis enregistrement (comportement UX demandé). + +## Propagation du mode (snapshot pattern existant, réutilisé tel quel) +`ScoreInputMode` (`manual|stopwatch`) : source de vérité sur `Exercise`, copié dans `ProgramExercise`, puis dans le snapshot de séance (`WorkoutTemplateProgram`/exercice snapshotté), puis dans `ActiveSetResult` et `WorkoutHistorySetResult` pour affichage autonome sans dépendre de la source. +**Important** : `WorkoutTemplateExerciseOverride` ne porte JAMAIS le mode lui-même (cohérent avec la règle produit déjà en place : l'override en séance-modèle ne change que des valeurs numériques, jamais la structure/le mode) — seulement `targetScoreTimeMsOverride` en plus des overrides numériques existants. + +## Migration Drift +Le schéma est actuellement en `schemaVersion = 2` (ticket #21). Le ticket #18 doit passer en **3** : ajout des colonnes mode/chrono sur les tables concernées + création de la table `ActiveScoreStopwatchState`, migration `from < 3`. \ No newline at end of file diff --git a/.ideai/memory/gametime-ux-score-chrono.md b/.ideai/memory/gametime-ux-score-chrono.md new file mode 100644 index 0000000..9ff333a --- /dev/null +++ b/.ideai/memory/gametime-ux-score-chrono.md @@ -0,0 +1,42 @@ +--- +name: gametime-ux-score-chrono +description: memory note gametime-ux-score-chrono +metadata: + type: project +--- +# GameTime — Score chronométré (ticket #18, UX 2026-07-18) + +Décision structurante : **le score chronométré est un MODE du Score existant** (`manual` vs `stopwatch`), pas une 4e mesure. Le mental model Temps/Répétitions/Score reste inchangé. + +## Configuration exercice +Si "Score" activé → sous-choix "Mode de saisie" : "Saisie libre" (comportement actuel, label+unité) ou "Chrono intégré" (label par défaut "Temps réalisé", pas d'unité libre, unité implicite "temps"). Badge résumé : "Score chrono" au lieu de "Score" pour ce mode. + +## Configuration programme (personnaliser l'exercice) +Si mesure = Score chrono : champ "Objectif de chrono" optionnel (aide : "Le résultat réel sera mesuré pendant la série."), pas de "cible score" classique. + +## Exécution de séance +Bloc dédié "Chrono score" avec affichage mm:ss.d et boutons Démarrer/Arrêter/Reprendre/Réinitialiser. Comportements clés : +- "Terminer la série" avec chrono non démarré → confirmation "Aucun temps chronométré / Tu n'as pas démarré le chrono score." avec actions "Démarrer le chrono" / "Terminer sans chrono". +- "Terminer la série" avec chrono en cours → arrête automatiquement et enregistre (comportement le plus utile en usage réel). +- "Passer" pendant que le chrono tourne → confirmation "Le chrono en cours sera ignoré." +- Pause de séance → le chrono score se met en pause avec la séance (jamais actif pendant une pause), reprise affiche "Reprendre le chrono". +- Action secondaire "Modifier le temps" pour corriger manuellement (champ min/s/dixièmes), disponible aussi dans la bottom sheet "Modifier la série" de la refonte de navigation (#23). +- Persistance robuste si app fermée pendant que le chrono tourne : horodatage de départ + accumulé, pas un simple compteur mémoire (cohérent avec le reste de l'app). + +## Cumul avec les autres mesures +- Avec Répétitions : oui, cas d'usage principal (ex: "10 suicides · chrono score"). +- Avec Score libre : non — un seul mode de score actif à la fois, pas les deux simultanément. +- Avec Temps (objectif) : possible mais avertissement explicite affiché en configuration si les deux sont actifs ensemble ("Temps sert d'objectif de durée ; Score chrono enregistre le temps réalisé."). + +## Cas limites +- Oubli de démarrer + "Terminer sans chrono" → série Terminée si reps/temps renseignés sinon Passée. +- Réinitialiser le chrono après arrêt avec valeur → confirmation ("Le temps mesuré sera supprimé."). +- Libellés utilisateur : jamais "Score (s)", toujours "Score chrono" dans les listes/badges et "Temps réalisé" dans le détail historique. + +## Découpage proposé par UX +1. Domain · mode de score (enum ScoreInputMode manual/stopwatch, propagation dans tous les snapshots). +2. Exercices/Programmes · configuration Score chrono (choix du mode, objectif de chrono). +3. Exécution · chrono score intégré (démarrer/arrêter/reprendre/réinitialiser, auto-stop, confirmations). +4. Historique/Plan · affichage Score chrono (résumé, détail, édition ponctuelle avec durée manuelle). + +Stockage recommandé par UX (à confirmer par Architect) : champ dédié `actualScoreTimeMs` plutôt que réutiliser `actualScore` en secondes — évite d'ambiguïser une durée avec un score numérique libre, facilite l'affichage mm:ss.d. \ No newline at end of file diff --git a/.ideai/tickets/12/carnet.md b/.ideai/tickets/12/carnet.md index b672a51..f52a273 100644 --- a/.ideai/tickets/12/carnet.md +++ b/.ideai/tickets/12/carnet.md @@ -1,7 +1,7 @@ --- issueRef: "#12" -version: 3 +version: 5 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784301400489 +updatedAt: 1784328818712 --- Portée volontairement limitée : pas de backend à développer. Juste s'assurer que change_log est alimenté de façon fiable à chaque mutation (create/update/delete/archive) sur les agrégats définis en #3, et qu'une interface de sync (no-op) existe pour ne pas avoir à toucher au domaine quand le serveur arrivera. Documenter aussi où s'accrochera la notion de profil utilisateur (futureOwnerProfileId, nullable pour l'instant). \ No newline at end of file diff --git a/.ideai/tickets/12/issue.md b/.ideai/tickets/12/issue.md index 9bd945c..1b7f8c7 100644 --- a/.ideai/tickets/12/issue.md +++ b/.ideai/tickets/12/issue.md @@ -2,7 +2,7 @@ id: "756cfcb4-8f5a-412f-ba13-5038284679bd" number: 12 title: "[DevBackend] Préparation technique de la synchro serveur future" -status: "open" +status: "closed" priority: "low" sprint: null links: [{"target":"#4","kind":"dependsOn"}] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784301314769 -updatedAt: 1784301400489 -version: 3 +updatedAt: 1784328818712 +version: 5 --- Alimenter la table change_log à chaque mutation d'agrégat. Définir une interface abstraite de synchronisation (no-op pour l'instant, sans backend serveur). Documenter les invariants nécessaires à une future synchro incrémentale multi-device (résolution de conflits, gestion des profils utilisateurs à venir). Pas de développement serveur à ce stade. \ No newline at end of file diff --git a/.ideai/tickets/24/carnet.md b/.ideai/tickets/24/carnet.md new file mode 100644 index 0000000..5550653 --- /dev/null +++ b/.ideai/tickets/24/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#24" +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1784328229518 +--- diff --git a/.ideai/tickets/24/issue.md b/.ideai/tickets/24/issue.md new file mode 100644 index 0000000..1268ae7 --- /dev/null +++ b/.ideai/tickets/24/issue.md @@ -0,0 +1,16 @@ +--- +id: "3dbea39a-1466-47d9-b12a-95a601ec287d" +number: 24 +title: "Popup \"Exercie ajouté\" qui ne s'enlève pas quand on ajoute un exercice" +status: "open" +priority: "high" +sprint: null +links: [] +agentRefs: [{"agentId":"57695b92-24d0-4876-837c-76116e70a6ae","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784328185359 +updatedAt: 1784328229518 +version: 3 +--- +Quand j'ajoute un exercice, la popup "Exercie ajouté" s'affiche mais ne s'enleve pas. Il faudrait qu'elle s'enleve au bout que 3 sec par exemple ou si on appuie sur Personnlaiser \ No newline at end of file diff --git a/.ideai/tickets/25/carnet.md b/.ideai/tickets/25/carnet.md new file mode 100644 index 0000000..7b85911 --- /dev/null +++ b/.ideai/tickets/25/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#25" +version: 1 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784328239814 +--- diff --git a/.ideai/tickets/25/issue.md b/.ideai/tickets/25/issue.md new file mode 100644 index 0000000..67d5233 --- /dev/null +++ b/.ideai/tickets/25/issue.md @@ -0,0 +1,16 @@ +--- +id: "da78f1df-ff9b-4745-b108-b312eec9efe7" +number: 25 +title: "[DevBackend] Domain et migration pour le score chronométré" +status: "open" +priority: "high" +sprint: null +links: [{"target":"#18","kind":"relatesTo"}] +agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784328239814 +updatedAt: 1784328239814 +version: 1 +--- +Fondation du ticket #18 (score chronométré). Ajouter ScoreInputMode (manual|stopwatch) sur Exercise avec propagation snapshot pattern (ProgramExercise, snapshot de séance, ActiveSetResult, WorkoutHistorySetResult). Ajouter actualScoreTimeMs/targetScoreTimeMs/targetScoreTimeMsOverride distincts de actualScore/targetScore/targetScoreOverride. Nouvelle table Drift ActiveScoreStopwatchState (status running|stopped, startedAt, accumulatedMs, stoppedAt, clé programIndex/exerciseIndex/setIndex). Migration Drift schemaVersion 2 -> 3. Use cases : démarrer/arrêter/reprendre/réinitialiser le chrono d'une série, copie de la durée finale vers ActiveSetResult à la validation. Cf. mémoires "gametime-ux-score-chrono" et "gametime-architecture-score-chrono" pour le détail complet. \ No newline at end of file diff --git a/.ideai/tickets/26/carnet.md b/.ideai/tickets/26/carnet.md new file mode 100644 index 0000000..1fa7919 --- /dev/null +++ b/.ideai/tickets/26/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#26" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784328255774 +--- diff --git a/.ideai/tickets/26/issue.md b/.ideai/tickets/26/issue.md new file mode 100644 index 0000000..3cb0910 --- /dev/null +++ b/.ideai/tickets/26/issue.md @@ -0,0 +1,16 @@ +--- +id: "37a022d8-b152-48c9-a4d1-72528e2d63af" +number: 26 +title: "[DevFrontend] Configuration du Score chrono (exercice + programme)" +status: "open" +priority: "high" +sprint: null +links: [{"target":"#18","kind":"relatesTo"},{"target":"#25","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784328244539 +updatedAt: 1784328255774 +version: 2 +--- +Dans la création/édition d'exercice (lib/presentation/exercise_library_screen.dart), si Score est activé, ajouter le sous-choix "Mode de saisie" : Saisie libre (actuel) vs Chrono intégré (label par défaut "Temps réalisé", pas d'unité libre). Badge résumé "Score chrono" pour ce mode. Dans l'écran de personnalisation d'exercice en programme (lib/presentation/program_screen.dart), pour un exercice en Score chrono : champ "Objectif de chrono" optionnel au lieu de "cible score". Avertissement si Temps ET Score chrono actifs simultanément. Cf. mémoire "gametime-ux-score-chrono" pour le détail complet. \ No newline at end of file diff --git a/.ideai/tickets/27/carnet.md b/.ideai/tickets/27/carnet.md new file mode 100644 index 0000000..35a82b9 --- /dev/null +++ b/.ideai/tickets/27/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#27" +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784328260160 +--- diff --git a/.ideai/tickets/27/issue.md b/.ideai/tickets/27/issue.md new file mode 100644 index 0000000..e0c674e --- /dev/null +++ b/.ideai/tickets/27/issue.md @@ -0,0 +1,16 @@ +--- +id: "1b0116c4-ccb5-4d58-b977-fbe122e043fa" +number: 27 +title: "[DevFrontend] Chrono score intégré dans l'exécution de séance" +status: "open" +priority: "high" +sprint: null +links: [{"target":"#18","kind":"relatesTo"},{"target":"#25","kind":"dependsOn"},{"target":"#26","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784328248186 +updatedAt: 1784328260160 +version: 3 +--- +Dans lib/presentation/workout_execution_screen.dart, bloc dédié "Chrono score" (affichage mm:ss.d, boutons Démarrer/Arrêter/Reprendre/Réinitialiser) quand la mesure Score est en mode chrono. Terminer la série avec chrono non démarré -> confirmation "Aucun temps chronométré" (Démarrer le chrono / Terminer sans chrono). Terminer avec chrono en cours -> auto-stop et enregistrement. Passer pendant que le chrono tourne -> confirmation. Pause de séance -> chrono se met en pause avec la séance. Action secondaire "Modifier le temps" pour correction manuelle. Cf. mémoire "gametime-ux-score-chrono" pour le détail complet des cas limites. \ No newline at end of file diff --git a/.ideai/tickets/28/carnet.md b/.ideai/tickets/28/carnet.md new file mode 100644 index 0000000..09dc37c --- /dev/null +++ b/.ideai/tickets/28/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#28" +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784328260662 +--- diff --git a/.ideai/tickets/28/issue.md b/.ideai/tickets/28/issue.md new file mode 100644 index 0000000..98140b4 --- /dev/null +++ b/.ideai/tickets/28/issue.md @@ -0,0 +1,16 @@ +--- +id: "dd96c2f3-63a7-4e0d-b976-b1e4a2f58240" +number: 28 +title: "[DevFrontend] Affichage du Score chrono dans le plan de séance et l'historique" +status: "open" +priority: "medium" +sprint: null +links: [{"target":"#18","kind":"relatesTo"},{"target":"#27","kind":"dependsOn"}] +agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}] +createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +createdAt: 1784328251633 +updatedAt: 1784328260662 +version: 2 +--- +Dans le plan de séance (bottom sheet de workout_execution_screen.dart) et l'écran d'historique (history_screen.dart) : afficher "Score chrono" avec la durée formatée (ex "00:38.7") au lieu d'un score numérique brut, avec l'objectif si défini ("00:38.7 / objectif 00:45"). La bottom sheet "Modifier la série" (édition ponctuelle, ticket #23) doit permettre de saisir/corriger manuellement une durée (min/s/dixièmes) pour une série en Score chrono. Cf. mémoire "gametime-ux-score-chrono". \ No newline at end of file diff --git a/.ideai/tickets/29/carnet.md b/.ideai/tickets/29/carnet.md new file mode 100644 index 0000000..b479c87 --- /dev/null +++ b/.ideai/tickets/29/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#29" +version: 3 +updatedBy: {"kind":"user"} +updatedAt: 1784328670579 +--- diff --git a/.ideai/tickets/29/issue.md b/.ideai/tickets/29/issue.md new file mode 100644 index 0000000..a5a85b8 --- /dev/null +++ b/.ideai/tickets/29/issue.md @@ -0,0 +1,16 @@ +--- +id: "89da284b-871c-4e5b-9bfa-ab16f386ba8f" +number: 29 +title: "[Bug] Serie qu'on ne peut pas temriner" +status: "open" +priority: "critical" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784328426827 +updatedAt: 1784328670579 +version: 3 +--- +Dans un programme j'ai un exercice qui se compose de 3 series, une mesure a suivre de temps, de répétition et un score. J'ai mis le temps cible à 30, le nombre de répétition à 5. Une fois dans ma séance, je suis sur la série 2/3, temps indiqué à 30 s, j'ai mis Répétitions sur 3 et score sur 5, mais quand je clique sur temriner la série, rien ne se passe \ No newline at end of file diff --git a/.ideai/tickets/30/carnet.md b/.ideai/tickets/30/carnet.md new file mode 100644 index 0000000..2539cfb --- /dev/null +++ b/.ideai/tickets/30/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#30" +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1784328751807 +--- diff --git a/.ideai/tickets/30/issue.md b/.ideai/tickets/30/issue.md new file mode 100644 index 0000000..c69f683 --- /dev/null +++ b/.ideai/tickets/30/issue.md @@ -0,0 +1,16 @@ +--- +id: "d4793c3f-a308-42f3-89e6-335cb2f95310" +number: 30 +title: "Ajouter ajouter des valeurs par défaut à la création d'un exercice" +status: "open" +priority: "critical" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784328704260 +updatedAt: 1784328751807 +version: 2 +--- +Il faut qu'a la création d'un exercice, il soit obligatoire de donner des valeurs par défaut pour les cibles choisies pour l'exercice. Il faut que ces valeurs oient positives et différentes de 0 pour éviter tout soucis. \ No newline at end of file diff --git a/.ideai/tickets/31/carnet.md b/.ideai/tickets/31/carnet.md new file mode 100644 index 0000000..1650265 --- /dev/null +++ b/.ideai/tickets/31/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#31" +version: 1 +updatedBy: {"kind":"user"} +updatedAt: 1784328786412 +--- diff --git a/.ideai/tickets/31/issue.md b/.ideai/tickets/31/issue.md new file mode 100644 index 0000000..bbbaf10 --- /dev/null +++ b/.ideai/tickets/31/issue.md @@ -0,0 +1,15 @@ +--- +id: "2f55c804-65e7-465a-9296-f66dd6db684a" +number: 31 +title: "[Bug] Fleche retour non fonctionnelle dans une seance en cours" +status: "open" +priority: "high" +sprint: null +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784328786412 +updatedAt: 1784328786412 +version: 1 +--- diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 2ada877..5981494 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 24 + "nextNumber": 32 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 037bffc..057ee58 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -137,13 +137,13 @@ "issueRef": "#12", "path": "12", "title": "[DevBackend] Préparation technique de la synchro serveur future", - "status": "open", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784301400489 + "updatedAt": 1784328818712 }, { "issueRef": "#13", @@ -262,6 +262,96 @@ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], "updatedAt": 1784327862613 + }, + { + "issueRef": "#24", + "path": "24", + "title": "Popup \"Exercie ajouté\" qui ne s'enlève pas quand on ajoute un exercice", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "57695b92-24d0-4876-837c-76116e70a6ae" + ], + "updatedAt": 1784328229518 + }, + { + "issueRef": "#25", + "path": "25", + "title": "[DevBackend] Domain et migration pour le score chronométré", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "10ee045b-1c41-479e-ba03-dceed9edd495" + ], + "updatedAt": 1784328239814 + }, + { + "issueRef": "#26", + "path": "26", + "title": "[DevFrontend] Configuration du Score chrono (exercice + programme)", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784328255774 + }, + { + "issueRef": "#27", + "path": "27", + "title": "[DevFrontend] Chrono score intégré dans l'exécution de séance", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784328260160 + }, + { + "issueRef": "#28", + "path": "28", + "title": "[DevFrontend] Affichage du Score chrono dans le plan de séance et l'historique", + "status": "open", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [ + "9933c93a-b8a1-4164-a3bb-7063fdad747d" + ], + "updatedAt": 1784328260662 + }, + { + "issueRef": "#29", + "path": "29", + "title": "[Bug] Serie qu'on ne peut pas temriner", + "status": "open", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784328670579 + }, + { + "issueRef": "#30", + "path": "30", + "title": "Ajouter ajouter des valeurs par défaut à la création d'un exercice", + "status": "open", + "priority": "critical", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784328751807 + }, + { + "issueRef": "#31", + "path": "31", + "title": "[Bug] Fleche retour non fonctionnelle dans une seance en cours", + "status": "open", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784328786412 } ] } \ No newline at end of file diff --git a/docs/sync-invariants.md b/docs/sync-invariants.md new file mode 100644 index 0000000..fbedf81 --- /dev/null +++ b/docs/sync-invariants.md @@ -0,0 +1,34 @@ +# Sync invariants + +GameTime remains offline-first. The current sync surface is preparatory only: +there is no server protocol, remote auth, or network adapter yet. + +## Local mutation log + +Every local mutation of a syncable aggregate must update the row metadata and +append a `change_log` entry in the same local transaction. + +Required row metadata: +- `id` is stable and generated locally. +- `updatedAt` is the logical mutation timestamp. +- `syncState` is `dirty` for local writes and `deleted` for soft deletes. +- `localRevision` increases when an existing row changes. +- `originDeviceId` identifies the device that produced the row mutation. +- `futureOwnerProfileId` is nullable until user profiles exist. + +`change_log.operation` currently supports `insert`, `update`, `softDelete`, and +`restore`. Exercise archive/unarchive is represented as `update` because the row +is not deleted and the current schema has no `archive` operation. + +## Future conflict policy + +Conflict resolution is not implemented yet. The intended first server strategy is +last-write-wins per entity using `updatedAt`, with `localRevision` and +`originDeviceId` retained for diagnostics and later upgrade. Vector clocks are not +implemented and are not assumed by the current model. + +## Future profiles + +The profile boundary will attach to `futureOwnerProfileId`. Until profile support +is added, rows keep it nullable and sync code must not infer ownership from local +device IDs. diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 76a6116..9044530 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -11,6 +11,7 @@ final class AppBootstrap { required this.activeWorkoutSessionUseCases, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, + required this.syncGateway, }); final AppDatabase database; @@ -21,6 +22,7 @@ final class AppBootstrap { final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; final WorkoutHistoryUseCases workoutHistoryUseCases; + final SyncGateway syncGateway; static Future create() async { final database = AppDatabase.open(); @@ -83,6 +85,7 @@ final class AppBootstrap { repository: historyRepository, clock: clock, ), + syncGateway: const NoOpSyncGateway(), ); } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 83f1981..5c46e1e 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -92,3 +92,26 @@ abstract interface class WorkoutHistoryRepository { Future saveSetResult(WorkoutHistorySetResult result); Future delete(String id, DateTime deletedAt); } + +final class SyncRunSummary { + const SyncRunSummary({ + required this.pushedChanges, + required this.pulledChanges, + }); + + final int pushedChanges; + final int pulledChanges; +} + +abstract interface class SyncGateway { + Future synchronize(); +} + +final class NoOpSyncGateway implements SyncGateway { + const NoOpSyncGateway(); + + @override + Future synchronize() async { + return const SyncRunSummary(pushedChanges: 0, pulledChanges: 0); + } +} diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 6931ec2..f479db5 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -45,9 +45,15 @@ final class DriftExerciseRepository implements ExerciseRepository { @override Future save(domain.Exercise exercise) async { - await database - .into(database.exercises) - .insertOnConflictUpdate(_exerciseCompanion(exercise)); + await _upsertWithChangeLog( + database: database, + tableName: 'exercises', + entityType: 'Exercise', + metadata: exercise.metadata, + write: () => database + .into(database.exercises) + .insertOnConflictUpdate(_exerciseCompanion(exercise)), + ); } } @@ -74,9 +80,15 @@ final class DriftMediaAssetRepository implements MediaAssetRepository { @override Future save(domain.MediaAsset mediaAsset) async { - await database - .into(database.mediaAssets) - .insertOnConflictUpdate(_mediaAssetCompanion(mediaAsset)); + await _upsertWithChangeLog( + database: database, + tableName: 'media_assets', + entityType: 'MediaAsset', + metadata: mediaAsset.metadata, + write: () => database + .into(database.mediaAssets) + .insertOnConflictUpdate(_mediaAssetCompanion(mediaAsset)), + ); } } @@ -133,19 +145,33 @@ final class DriftProgramRepository implements ProgramRepository { @override Future save(domain.Program program) async { - await database - .into(database.programs) - .insertOnConflictUpdate(_programCompanion(program)); - for (final exercise in program.exercises) { - await saveExercise(exercise); - } + await database.transaction(() async { + await _upsertWithChangeLog( + database: database, + tableName: 'programs', + entityType: 'Program', + metadata: program.metadata, + write: () => database + .into(database.programs) + .insertOnConflictUpdate(_programCompanion(program)), + ); + for (final exercise in program.exercises) { + await saveExercise(exercise); + } + }); } @override Future saveExercise(domain.ProgramExercise exercise) async { - await database - .into(database.programExercises) - .insertOnConflictUpdate(_programExerciseCompanion(exercise)); + await _upsertWithChangeLog( + database: database, + tableName: 'program_exercises', + entityType: 'ProgramExercise', + metadata: exercise.metadata, + write: () => database + .into(database.programExercises) + .insertOnConflictUpdate(_programExerciseCompanion(exercise)), + ); } @override @@ -158,26 +184,25 @@ final class DriftProgramRepository implements ProgramRepository { final activeIds = program.exercises .map((exercise) => exercise.metadata.id) .toSet(); - final update = database.update(database.programExercises); + final staleQuery = database.select(database.programExercises); if (activeIds.isEmpty) { - update.where( + staleQuery.where( (table) => table.programId.equals(program.metadata.id) & table.deletedAt.isNull(), ); } else { - update.where( + staleQuery.where( (table) => table.programId.equals(program.metadata.id) & table.deletedAt.isNull() & table.id.isNotIn(activeIds), ); } - await update.write( - db.ProgramExercisesCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), - ), + await _softDeleteProgramExerciseRows( + database, + await staleQuery.get(), + deletedAt, ); }); } @@ -244,33 +269,53 @@ final class DriftWorkoutTemplateRepository @override Future save(domain.WorkoutTemplate template) async { - await database - .into(database.workoutTemplates) - .insertOnConflictUpdate(_workoutTemplateCompanion(template)); - for (final program in template.programs) { - await saveProgram(program); - } - for (final override in template.overrides) { - await saveOverride(override); - } + await database.transaction(() async { + await _upsertWithChangeLog( + database: database, + tableName: 'workout_templates', + entityType: 'WorkoutTemplate', + metadata: template.metadata, + write: () => database + .into(database.workoutTemplates) + .insertOnConflictUpdate(_workoutTemplateCompanion(template)), + ); + for (final program in template.programs) { + await saveProgram(program); + } + for (final override in template.overrides) { + await saveOverride(override); + } + }); } @override Future saveProgram(domain.WorkoutTemplateProgram program) async { - await database - .into(database.workoutTemplatePrograms) - .insertOnConflictUpdate(_workoutTemplateProgramCompanion(program)); + await _upsertWithChangeLog( + database: database, + tableName: 'workout_template_programs', + entityType: 'WorkoutTemplateProgram', + metadata: program.metadata, + write: () => database + .into(database.workoutTemplatePrograms) + .insertOnConflictUpdate(_workoutTemplateProgramCompanion(program)), + ); } @override Future saveOverride( domain.WorkoutTemplateExerciseOverride override, ) async { - await database - .into(database.workoutTemplateExerciseOverrides) - .insertOnConflictUpdate( - _workoutTemplateExerciseOverrideCompanion(override), - ); + await _upsertWithChangeLog( + database: database, + tableName: 'workout_template_exercise_overrides', + entityType: 'WorkoutTemplateExerciseOverride', + metadata: override.metadata, + write: () => database + .into(database.workoutTemplateExerciseOverrides) + .insertOnConflictUpdate( + _workoutTemplateExerciseOverrideCompanion(override), + ), + ); } @override @@ -293,26 +338,27 @@ final class DriftWorkoutTemplateRepository final activeProgramIds = template.programs .map((program) => program.metadata.id) .toSet(); - final programUpdate = database.update(database.workoutTemplatePrograms); + final staleProgramsQuery = database.select( + database.workoutTemplatePrograms, + ); if (activeProgramIds.isEmpty) { - programUpdate.where( + staleProgramsQuery.where( (table) => table.workoutTemplateId.equals(template.metadata.id) & table.deletedAt.isNull(), ); } else { - programUpdate.where( + staleProgramsQuery.where( (table) => table.workoutTemplateId.equals(template.metadata.id) & table.deletedAt.isNull() & table.id.isNotIn(activeProgramIds), ); } - await programUpdate.write( - db.WorkoutTemplateProgramsCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), - ), + await _softDeleteWorkoutTemplateProgramRows( + database, + await staleProgramsQuery.get(), + deletedAt, ); final activeOverrideIds = template.overrides @@ -322,28 +368,27 @@ final class DriftWorkoutTemplateRepository if (relevantProgramIds.isEmpty) { return; } - final overrideUpdate = database.update( + final staleOverridesQuery = database.select( database.workoutTemplateExerciseOverrides, ); if (activeOverrideIds.isEmpty) { - overrideUpdate.where( + staleOverridesQuery.where( (table) => table.workoutTemplateProgramId.isIn(relevantProgramIds) & table.deletedAt.isNull(), ); } else { - overrideUpdate.where( + staleOverridesQuery.where( (table) => table.workoutTemplateProgramId.isIn(relevantProgramIds) & table.deletedAt.isNull() & table.id.isNotIn(activeOverrideIds), ); } - await overrideUpdate.write( - db.WorkoutTemplateExerciseOverridesCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), - ), + await _softDeleteWorkoutTemplateExerciseOverrideRows( + database, + await staleOverridesQuery.get(), + deletedAt, ); }); } @@ -379,23 +424,41 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { @override Future save(domain.ActiveWorkoutSession session) async { - await database - .into(database.activeWorkoutSessions) - .insertOnConflictUpdate(_activeWorkoutSessionCompanion(session)); + await _upsertWithChangeLog( + database: database, + tableName: 'active_workout_sessions', + entityType: 'ActiveWorkoutSession', + metadata: session.metadata, + write: () => database + .into(database.activeWorkoutSessions) + .insertOnConflictUpdate(_activeWorkoutSessionCompanion(session)), + ); } @override Future saveSetResult(domain.ActiveSetResult result) async { - await database - .into(database.activeSetResults) - .insertOnConflictUpdate(_activeSetResultCompanion(result)); + await _upsertWithChangeLog( + database: database, + tableName: 'active_set_results', + entityType: 'ActiveSetResult', + metadata: result.metadata, + write: () => database + .into(database.activeSetResults) + .insertOnConflictUpdate(_activeSetResultCompanion(result)), + ); } @override Future saveRestState(domain.ActiveRestState restState) async { - await database - .into(database.activeRestStates) - .insertOnConflictUpdate(_activeRestStateCompanion(restState)); + await _upsertWithChangeLog( + database: database, + tableName: 'active_rest_states', + entityType: 'ActiveRestState', + metadata: restState.metadata, + write: () => database + .into(database.activeRestStates) + .insertOnConflictUpdate(_activeRestStateCompanion(restState)), + ); } @override @@ -484,71 +547,307 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future save(domain.WorkoutHistory history) async { - await database - .into(database.workoutHistories) - .insertOnConflictUpdate(_workoutHistoryCompanion(history)); - for (final result in history.results) { - await saveSetResult(result); - } + await database.transaction(() async { + await _upsertWithChangeLog( + database: database, + tableName: 'workout_history', + entityType: 'WorkoutHistory', + metadata: history.metadata, + write: () => database + .into(database.workoutHistories) + .insertOnConflictUpdate(_workoutHistoryCompanion(history)), + ); + for (final result in history.results) { + await saveSetResult(result); + } + }); } @override Future saveSetResult(domain.WorkoutHistorySetResult result) async { - await database - .into(database.workoutHistorySetResults) - .insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)); + await _upsertWithChangeLog( + database: database, + tableName: 'workout_history_set_results', + entityType: 'WorkoutHistorySetResult', + metadata: result.metadata, + write: () => database + .into(database.workoutHistorySetResults) + .insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)), + ); } @override Future delete(String id, DateTime deletedAt) async { await database.transaction(() async { + final history = await (database.select( + database.workoutHistories, + )..where((table) => table.id.equals(id))).getSingle(); + final revision = history.localRevision + 1; await (database.update( database.workoutHistories, )..where((table) => table.id.equals(id))).write( db.WorkoutHistoriesCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), ), ); - await (database.update( - database.workoutHistorySetResults, - )..where((table) => table.workoutHistoryId.equals(id))).write( - db.WorkoutHistorySetResultsCompanion( - deletedAt: Value(deletedAt), - updatedAt: Value(deletedAt), - ), + await _writeChangeLog( + database: database, + entityType: 'WorkoutHistory', + entityId: id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: history.originDeviceId, + createdAt: deletedAt, + ); + final resultRows = + await (database.select(database.workoutHistorySetResults)..where( + (table) => + table.workoutHistoryId.equals(id) & + table.deletedAt.isNull(), + )) + .get(); + await _softDeleteWorkoutHistorySetResultRows( + database, + resultRows, + deletedAt, ); }); } } +Future _upsertWithChangeLog({ + required db.AppDatabase database, + required String tableName, + required String entityType, + required domain.EntityMetadata metadata, + required Future Function() write, +}) async { + await database.transaction(() async { + final operation = await _operationForMutation( + database: database, + tableName: tableName, + entityId: metadata.id, + deletedAt: metadata.deletedAt, + ); + await write(); + await _writeChangeLog( + database: database, + entityType: entityType, + entityId: metadata.id, + operation: operation, + localRevision: metadata.localRevision, + originDeviceId: metadata.originDeviceId, + createdAt: metadata.updatedAt, + ); + }); +} + +Future _operationForMutation({ + required db.AppDatabase database, + required String tableName, + required String entityId, + required DateTime? deletedAt, +}) async { + final existing = await database + .customSelect( + 'SELECT deleted_at FROM $tableName WHERE id = ? LIMIT 1', + variables: [Variable(entityId)], + ) + .getSingleOrNull(); + if (existing == null) { + return 'insert'; + } + final wasDeleted = existing.data['deleted_at'] != null; + if (deletedAt != null) { + return 'softDelete'; + } + if (wasDeleted) { + return 'restore'; + } + return 'update'; +} + +Future _writeChangeLog({ + required db.AppDatabase database, + required String entityType, + required String entityId, + required String operation, + required int localRevision, + required String originDeviceId, + required DateTime createdAt, +}) async { + final id = [ + entityType, + entityId, + operation, + localRevision, + createdAt.microsecondsSinceEpoch, + ].join(':'); + await database + .into(database.changeLogEntries) + .insertOnConflictUpdate( + db.ChangeLogEntriesCompanion.insert( + id: id, + entityType: entityType, + entityId: entityId, + operation: operation, + localRevision: localRevision, + originDeviceId: originDeviceId, + createdAt: createdAt.toUtc(), + ), + ); +} + +Future _softDeleteProgramExerciseRows( + db.AppDatabase database, + List rows, + DateTime deletedAt, +) async { + for (final row in rows) { + final revision = row.localRevision + 1; + await (database.update( + database.programExercises, + )..where((table) => table.id.equals(row.id))).write( + db.ProgramExercisesCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); + await _writeChangeLog( + database: database, + entityType: 'ProgramExercise', + entityId: row.id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.originDeviceId, + createdAt: deletedAt, + ); + } +} + +Future _softDeleteWorkoutTemplateProgramRows( + db.AppDatabase database, + List rows, + DateTime deletedAt, +) async { + for (final row in rows) { + final revision = row.localRevision + 1; + await (database.update( + database.workoutTemplatePrograms, + )..where((table) => table.id.equals(row.id))).write( + db.WorkoutTemplateProgramsCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); + await _writeChangeLog( + database: database, + entityType: 'WorkoutTemplateProgram', + entityId: row.id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.originDeviceId, + createdAt: deletedAt, + ); + } +} + +Future _softDeleteWorkoutTemplateExerciseOverrideRows( + db.AppDatabase database, + List rows, + DateTime deletedAt, +) async { + for (final row in rows) { + final revision = row.localRevision + 1; + await (database.update( + database.workoutTemplateExerciseOverrides, + )..where((table) => table.id.equals(row.id))).write( + db.WorkoutTemplateExerciseOverridesCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); + await _writeChangeLog( + database: database, + entityType: 'WorkoutTemplateExerciseOverride', + entityId: row.id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.originDeviceId, + createdAt: deletedAt, + ); + } +} + +Future _softDeleteWorkoutHistorySetResultRows( + db.AppDatabase database, + List rows, + DateTime deletedAt, +) async { + for (final row in rows) { + final revision = row.localRevision + 1; + await (database.update( + database.workoutHistorySetResults, + )..where((table) => table.id.equals(row.id))).write( + db.WorkoutHistorySetResultsCompanion( + deletedAt: Value(deletedAt.toUtc()), + updatedAt: Value(deletedAt.toUtc()), + localRevision: Value(revision), + syncState: const Value('deleted'), + ), + ); + await _writeChangeLog( + database: database, + entityType: 'WorkoutHistorySetResult', + entityId: row.id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.originDeviceId, + createdAt: deletedAt, + ); + } +} + +DateTime _utc(DateTime value) => value.toUtc(); + +DateTime? _utcOrNull(DateTime? value) => value?.toUtc(); + domain.EntityMetadata _metadataFromRow(dynamic row) { return domain.EntityMetadata( id: row.id as String, - createdAt: row.createdAt as DateTime, - updatedAt: row.updatedAt as DateTime, - deletedAt: row.deletedAt as DateTime?, + createdAt: _utc(row.createdAt as DateTime), + updatedAt: _utc(row.updatedAt as DateTime), + deletedAt: _utcOrNull(row.deletedAt as DateTime?), schemaVersion: row.schemaVersion as int, syncState: _syncStateFromDb(row.syncState as String), localRevision: row.localRevision as int, originDeviceId: row.originDeviceId as String, futureOwnerProfileId: row.futureOwnerProfileId as String?, - lastSyncedAt: row.lastSyncedAt as DateTime?, + lastSyncedAt: _utcOrNull(row.lastSyncedAt as DateTime?), remoteRevision: row.remoteRevision as String?, ); } List _metadataValues(domain.EntityMetadata metadata) => [ Value(metadata.id), - Value(metadata.createdAt), - Value(metadata.updatedAt), - Value(metadata.deletedAt), + Value(metadata.createdAt.toUtc()), + Value(metadata.updatedAt.toUtc()), + Value(_utcOrNull(metadata.deletedAt)), Value(metadata.schemaVersion), Value(_syncStateToDb(metadata.syncState)), Value(metadata.localRevision), Value(metadata.originDeviceId), Value(metadata.futureOwnerProfileId), - Value(metadata.lastSyncedAt), + Value(_utcOrNull(metadata.lastSyncedAt)), Value(metadata.remoteRevision), ]; @@ -575,7 +874,7 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) { hasScoreMeasure: Value(exercise.hasScoreMeasure), scoreLabel: Value(exercise.scoreLabel), scoreUnit: Value(exercise.scoreUnit), - archivedAt: Value(exercise.archivedAt), + archivedAt: Value(_utcOrNull(exercise.archivedAt)), ); } @@ -591,7 +890,7 @@ domain.Exercise _exerciseFromRow(db.Exercise row) { hasScoreMeasure: row.hasScoreMeasure, scoreLabel: row.scoreLabel, scoreUnit: row.scoreUnit, - archivedAt: row.archivedAt, + archivedAt: _utcOrNull(row.archivedAt), ); } @@ -753,7 +1052,7 @@ db.WorkoutTemplatesCompanion _workoutTemplateCompanion( lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, name: Value(template.name), - lastStartedAt: Value(template.lastStartedAt), + lastStartedAt: Value(_utcOrNull(template.lastStartedAt)), ); } @@ -765,7 +1064,7 @@ domain.WorkoutTemplate _workoutTemplateFromRow( return domain.WorkoutTemplate( metadata: _metadataFromRow(row), name: row.name, - lastStartedAt: row.lastStartedAt, + lastStartedAt: _utcOrNull(row.lastStartedAt), programs: programs, overrides: overrides, ); @@ -868,10 +1167,10 @@ db.ActiveWorkoutSessionsCompanion _activeWorkoutSessionCompanion( remoteRevision: values[10] as Value, sourceWorkoutTemplateId: Value(session.sourceWorkoutTemplateId), status: Value(_activeStatusToDb(session.status)), - startedAt: Value(session.startedAt), - pausedAt: Value(session.pausedAt), - endedAt: Value(session.endedAt), - lastPersistedAt: Value(session.lastPersistedAt), + startedAt: Value(session.startedAt.toUtc()), + pausedAt: Value(_utcOrNull(session.pausedAt)), + endedAt: Value(_utcOrNull(session.endedAt)), + lastPersistedAt: Value(session.lastPersistedAt.toUtc()), elapsedActiveMs: Value(session.elapsedActiveMs), currentProgramIndex: Value(session.currentProgramIndex), currentExerciseIndex: Value(session.currentExerciseIndex), @@ -887,10 +1186,10 @@ domain.ActiveWorkoutSession _activeWorkoutSessionFromRow( metadata: _metadataFromRow(row), sourceWorkoutTemplateId: row.sourceWorkoutTemplateId, status: _activeStatusFromDb(row.status), - startedAt: row.startedAt, - pausedAt: row.pausedAt, - endedAt: row.endedAt, - lastPersistedAt: row.lastPersistedAt, + startedAt: _utc(row.startedAt), + pausedAt: _utcOrNull(row.pausedAt), + endedAt: _utcOrNull(row.endedAt), + lastPersistedAt: _utc(row.lastPersistedAt), elapsedActiveMs: row.elapsedActiveMs, currentProgramIndex: row.currentProgramIndex, currentExerciseIndex: row.currentExerciseIndex, @@ -921,8 +1220,8 @@ db.ActiveSetResultsCompanion _activeSetResultCompanion( programIndex: Value(result.programIndex), exerciseIndex: Value(result.exerciseIndex), setIndex: Value(result.setIndex), - startedAt: Value(result.startedAt), - completedAt: Value(result.completedAt), + startedAt: Value(_utcOrNull(result.startedAt)), + completedAt: Value(_utcOrNull(result.completedAt)), actualTimeMs: Value(result.actualTimeMs), actualReps: Value(result.actualReps), actualScore: Value(result.actualScore), @@ -942,8 +1241,8 @@ domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) { programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, - startedAt: row.startedAt, - completedAt: row.completedAt, + startedAt: _utcOrNull(row.startedAt), + completedAt: _utcOrNull(row.completedAt), actualTimeMs: row.actualTimeMs, actualReps: row.actualReps, actualScore: row.actualScore, @@ -976,9 +1275,9 @@ db.ActiveRestStatesCompanion _activeRestStateCompanion( afterSetIndex: Value(restState.afterSetIndex), plannedRestSeconds: Value(restState.plannedRestSeconds), adjustedRestSeconds: Value(restState.adjustedRestSeconds), - startedAt: Value(restState.startedAt), - endedAt: Value(restState.endedAt), - skippedAt: Value(restState.skippedAt), + startedAt: Value(restState.startedAt.toUtc()), + endedAt: Value(_utcOrNull(restState.endedAt)), + skippedAt: Value(_utcOrNull(restState.skippedAt)), ); } @@ -991,9 +1290,9 @@ domain.ActiveRestState _activeRestStateFromRow(db.ActiveRestState row) { afterSetIndex: row.afterSetIndex, plannedRestSeconds: row.plannedRestSeconds, adjustedRestSeconds: row.adjustedRestSeconds, - startedAt: row.startedAt, - endedAt: row.endedAt, - skippedAt: row.skippedAt, + startedAt: _utc(row.startedAt), + endedAt: _utcOrNull(row.endedAt), + skippedAt: _utcOrNull(row.skippedAt), ); } @@ -1016,8 +1315,8 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion( sourceWorkoutTemplateId: Value(history.sourceWorkoutTemplateId), sourceActiveWorkoutSessionId: Value(history.sourceActiveWorkoutSessionId), nameSnapshot: Value(history.nameSnapshot), - startedAt: Value(history.startedAt), - endedAt: Value(history.endedAt), + startedAt: Value(history.startedAt.toUtc()), + endedAt: Value(history.endedAt.toUtc()), totalActiveMs: Value(history.totalActiveMs), completed: Value(history.completed), historySnapshotJson: Value(history.historySnapshotJson), @@ -1059,8 +1358,8 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion( actualScore: Value(result.actualScore), scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), - startedAt: Value(result.startedAt), - completedAt: Value(result.completedAt), + startedAt: Value(_utcOrNull(result.startedAt)), + completedAt: Value(_utcOrNull(result.completedAt)), status: Value(_setResultStatusToDb(result.status)), ); } @@ -1074,8 +1373,8 @@ domain.WorkoutHistory _workoutHistoryFromRow( sourceWorkoutTemplateId: row.sourceWorkoutTemplateId, sourceActiveWorkoutSessionId: row.sourceActiveWorkoutSessionId, nameSnapshot: row.nameSnapshot, - startedAt: row.startedAt, - endedAt: row.endedAt, + startedAt: _utc(row.startedAt), + endedAt: _utc(row.endedAt), totalActiveMs: row.totalActiveMs, completed: row.completed, historySnapshotJson: row.historySnapshotJson, @@ -1107,8 +1406,8 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow( actualScore: row.actualScore, scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, - startedAt: row.startedAt, - completedAt: row.completedAt, + startedAt: _utcOrNull(row.startedAt), + completedAt: _utcOrNull(row.completedAt), status: _setResultStatusFromDb(row.status), ); } diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index be44f69..663da31 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; @@ -8,12 +9,16 @@ import 'package:gametime/infrastructure/local/local.dart' as local; void main() { late local.AppDatabase database; + late local.DriftExerciseRepository exerciseRepository; + late local.DriftProgramRepository programRepository; late local.DriftActiveSessionRepository activeRepository; late local.DriftWorkoutTemplateRepository templateRepository; late local.DriftWorkoutHistoryRepository historyRepository; setUp(() { database = local.AppDatabase(NativeDatabase.memory()); + exerciseRepository = local.DriftExerciseRepository(database); + programRepository = local.DriftProgramRepository(database); activeRepository = local.DriftActiveSessionRepository(database); templateRepository = local.DriftWorkoutTemplateRepository(database); historyRepository = local.DriftWorkoutHistoryRepository(database); @@ -23,6 +28,102 @@ void main() { await database.close(); }); + test('exercise mutations are written to change log', () async { + final now = DateTime.utc(2026, 7, 17, 12); + final exercise = Exercise( + metadata: _metadata('exercise-1', now), + name: 'Squat', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + ); + + await exerciseRepository.save(exercise); + await exerciseRepository.save( + exercise.copyWith( + metadata: _metadata( + 'exercise-1', + now.add(const Duration(seconds: 1)), + 1, + ), + name: 'Front squat', + ), + ); + await exerciseRepository.save( + exercise + .copyWith( + metadata: _metadata( + 'exercise-1', + now.add(const Duration(seconds: 2)), + 2, + ), + name: 'Front squat', + ) + .archive(now.add(const Duration(seconds: 2))), + ); + + final changes = + await (database.select(database.changeLogEntries) + ..where((table) => table.entityId.equals('exercise-1')) + ..orderBy([ + (table) => drift.OrderingTerm.asc(table.localRevision), + ])) + .get(); + + expect(changes.map((change) => change.operation), [ + 'insert', + 'update', + 'update', + ]); + expect(changes.every((change) => change.entityType == 'Exercise'), isTrue); + }); + + test( + 'removing a program exercise writes a soft delete change log entry', + () async { + final now = DateTime.utc(2026, 7, 17, 12); + final first = _programExercise('program-exercise-1', now, position: 0); + final second = _programExercise('program-exercise-2', now, position: 1); + await programRepository.save( + Program( + metadata: _metadata('program-1', now), + name: 'Jambes', + defaultRestSeconds: 60, + exercises: [first, second], + ), + ); + + final deletedAt = now.add(const Duration(minutes: 1)); + await programRepository.replaceExercises( + Program( + metadata: _metadata('program-1', deletedAt, 1), + name: 'Jambes', + defaultRestSeconds: 60, + exercises: [first], + ), + deletedAt, + ); + + final deletedRow = await (database.select( + database.programExercises, + )..where((table) => table.id.equals('program-exercise-2'))).getSingle(); + final change = + await (database.select(database.changeLogEntries)..where( + (table) => + table.entityType.equals('ProgramExercise') & + table.entityId.equals('program-exercise-2') & + table.operation.equals('softDelete'), + )) + .getSingleOrNull(); + + expect(deletedRow.deletedAt?.toUtc(), deletedAt); + expect(deletedRow.syncState, 'deleted'); + expect(deletedRow.localRevision, 1); + expect(change, isNotNull); + expect(change!.localRevision, 1); + }, + ); + test( 'running session elapsed time survives repository reconstruction', () async { @@ -167,12 +268,33 @@ String _resolvedSnapshot() { }); } -EntityMetadata _metadata(String id, DateTime now) { +EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) { return EntityMetadata( id: id, createdAt: now, updatedAt: now, originDeviceId: 'device-1', + localRevision: localRevision, + ); +} + +ProgramExercise _programExercise( + String id, + DateTime now, { + required int position, +}) { + return ProgramExercise( + metadata: _metadata(id, now), + programId: 'program-1', + position: position, + exerciseNameSnapshot: 'Exercise $position', + availableTimeSnapshot: false, + availableRepsSnapshot: true, + availableScoreSnapshot: false, + setsCount: 1, + timeEnabled: false, + repsEnabled: true, + scoreEnabled: false, ); }