Files
GameTime/.ideai/tickets/119/carnet.md
Blomios 917777e18b chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:48:54 +02:00

102 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
issueRef: "#119"
version: 6
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1785137408716
---
## #119 — Extension watch bridge pour score +/- (Architect, 2026-07-26)
Statut : **cadrage exploitable, prêt pour DevBackend/DevFrontend**, pas de blocage. Basé sur le cadrage UX #118 et l'existant réel (`packages/watch_bridge_contract`, `WatchCompanionCommandHandler`, cf. [[gametime-watch-companion-implementation]]).
### Portée fonctionnelle
Ce ticket couvre le **score en mode `manual`** (`ScoreInputMode.manual`) uniquement. Le score chronométré (`stopwatch`, cf. [[gametime-architecture-score-chrono]]) ne s'"incrémente" pas — il se démarre/arrête ; il n'est pas concerné par ce +/- et reste hors périmètre ici.
### Constat sur l'existant
- `WatchCommandEnvelope` (dans `watch_bridge_contract`) n'a **aucun champ payload générique** — chaque commande existante est un `WatchCommandType` sans paramètre, tout l'effet dérivant de l'état serveur + du type. On reste cohérent avec ce pattern : pas de champ `delta` générique, deux types discrets.
- **Aucun état vivant n'existe aujourd'hui pour le score manuel pendant une série** — contrairement au score chrono qui a `ActiveScoreStopwatchState` (persistant, clé `sessionId+programIndex+exerciseIndex+setIndex`). Le score manuel est aujourd'hui un simple `TextEditingController` côté UI, lu une seule fois au moment de "Terminer la série" (`workout_execution_screen.dart`). Ce n'est pas suffisant : le +/- montre doit être visible en direct, y compris côté téléphone si l'app est au premier plan.
### Décision structurante : le score manuel devient un état de séance vivant
Créer une nouvelle table Drift/entité **`ActiveManualScoreState`**, sur exactement le même principe que `ActiveScoreStopwatchState` :
```dart
class ActiveManualScoreState {
final int programIndex, exerciseIndex, setIndex; // même clé logique que ActiveScoreStopwatchState
final double value; // valeur courante, plancher 0
final DateTime updatedAt;
}
```
- Absence de ligne = valeur par défaut `0`.
- Port repository : `findManualScoreState` / `saveManualScoreState` / `deleteManualScoreState` sur `ActiveSessionRepository`, en parallèle des méthodes `*ScoreStopwatchState` existantes.
- Suppression de l'état à la fin/passage de la série (même cycle de vie que le chrono score).
- **Plancher** : `0` fixe pour le MVP. Il n'existe aujourd'hui aucun champ "score minimum" configurable sur `Exercise`/`ProgramExercise` — en ajouter un serait un cadrage spéculatif hors demande explicite. Si un plancher par exercice est réellement voulu, c'est un ticket séparé, pas une extension implicite ici.
- **Pas de plafond** au MVP (cohérent avec le champ libre actuel qui n'a pas de borne haute).
- **Pas de palier configurable** : incrément fixe `+1`/`-1` (cas d'usage dominant : tally de points/répétitions comptés un par un). Pas de champ "step" à inventer sans besoin exprimé.
### Use cases (application layer)
Deux nouvelles méthodes sur `ActiveWorkoutSessionUseCases`, miroir de `startScoreStopwatch`/`stopScoreStopwatch` :
```dart
Future<void> incrementManualScore(sessionId, ...position...);
Future<void> decrementManualScore(sessionId, ...position...); // no-op si déjà à 0
```
`recordCurrentSetResult(...)` doit lire `ActiveManualScoreState.value` comme **valeur par défaut** de `actualScore` pour la série courante — mais rester **surchageable** par une édition manuelle explicite côté téléphone au moment de terminer la série (même mécanique que "Modifier le temps" déjà en place pour le score chrono, cf. [[gametime-architecture-score-chrono]]) : le paramètre existant de `recordCurrentSetResult` prend priorité s'il est explicitement fourni par l'UI, sinon on retombe sur l'état vivant.
### Pivot UI téléphone (important, à transmettre à DevFrontend)
Le score manuel n'est plus "un champ texte soumis une fois à la fin" — c'est désormais un **état de séance vivant**, au même titre que le chrono score. Le champ texte actuel (`_scoreController`) doit être re-branché en lecture/écriture sur `ActiveManualScoreState` (via le flux de projection/session déjà utilisé pour le reste de l'écran d'exécution), pour que toute modification faite depuis la montre soit visible immédiatement à l'écran si le téléphone est au premier plan.
### Extension du contrat `watch_bridge_contract`
```dart
enum WatchCommandType {
..., // types existants inchangés
incrementScore,
decrementScore,
}
```
Ajout par extension d'enum (Open/Closed) — aucune modification des types/cas existants.
`WatchSessionProjection` (phone→watch) doit exposer deux champs supplémentaires pour que la montre sache afficher/masquer le bloc et l'état du bouton `` sans dupliquer la logique métier :
```dart
final bool hasManualScore; // bloc absent sur la montre si false (cf. #118 UX)
final double? currentManualScoreValue; // valeur affichée, null si hasManualScore == false
final bool canDecrementScore; // false si déjà au plancher — logique du plancher reste calculée côté téléphone, jamais dupliquée sur la montre (montre = satellite, cf. [[gametime-watch-companion-implementation]])
```
### Idempotence et applicabilité (`WatchCompanionCommandHandler`)
- `_isApplicable()` : `incrementScore`/`decrementScore` applicables ssi phase de série active **et** `hasManualScore == true` sur la projection courante. Ajouter cette entrée sans toucher aux cas existants.
- **Point d'attention explicite pour l'implémentation** : contrairement à des commandes ponctuelles (ex. `finishCurrentSet`), le +/- est tapé **rapidement et répétitivement** (cf. UX #118 : "usage répété rapide"). L'applicabilité de ces deux types ne doit **pas** dépendre de l'exactitude de `expectedRevision` — seulement de la phase/du mode. Chaque tap génère un nouveau `commandId` (idempotence par commande individuelle, déjà gérée par `_handledCommands`), mais un léger décalage de révision entre deux taps rapprochés (le premier tap ayant déjà fait avancer la révision avant que le second parte) ne doit **jamais** produire `rejectedStaleRevision` pour ces deux types — sinon l'usage répété rapide voulu par l'UX serait cassé. C'est un cas différent des commandes de transition d'étape, qui elles peuvent légitimement dépendre de la révision exacte.
- Chaque commande acceptée déclenche `_emitProjectionAfterCommand()` comme les autres — republie vers la montre (valeur + `canDecrementScore` à jour) et vers le futur coordinateur de notification (#109) sans code spécifique supplémentaire.
### Comportement en cas de latence/échec (UX #118, déjà cadré, rappel technique)
Aucun contrat supplémentaire requis ici : le mécanisme optimiste "tap local → état atténué → confirmation" est **entièrement côté montre/présentation** (watch_app), consommant `currentManualScoreValue`/révision déjà remontés. Pas de nouvel état serveur à créer pour ça.
### Migration
Nouvelle table Drift `ActiveManualScoreState` ⇒ bump du `schemaVersion` local (vérifier la valeur courante au moment de l'implémentation, ne pas supposer un numéro figé ici).
### Tests
- Contrat : `dart test` sur les deux nouveaux `WatchCommandType`, même fichier `packages/watch_bridge_contract/test/`.
- Handler : étendre `test/application/watch_companion_command_handler_test.dart` — cas incrément/décrément normal, décrément au plancher (no-op, ack `acceptedNoOp`), applicabilité refusée hors mode manuel, rafale de commandes avec révisions décalées (non rejetées).
- Projection : étendre `test/application/watch_companion_projection_test.dart` pour les 3 nouveaux champs.
- Use case : test `recordCurrentSetResult` avec état vivant présent + override manuel explicite.
---
## Audit Architect — implémentation constatée dans le worktree (2026-07-27)
Statut mis à jour : **implémenté de bout en bout, conforme au cadrage**, un garde-fou de cohérence à fermer.
Vérification du code réel : `ActiveManualScoreState` (`lib/domain/entities.dart`, plancher enforced en plus dans le constructeur via `_requireNullableNonNegativeDouble`), CRUD complet sur `ActiveSessionRepository`/`DriftActiveSessionRepository`, `incrementManualScore`/`decrementManualScore`/`_updateManualScore` (`use_cases.dart`, clamp `[0, +∞)`, `ManualScoreUpdateResult.changed` pour distinguer accepté/no-op), `_allowsStaleRevision(type)` dans `WatchCompanionCommandHandler` qui exempte précisément `incrementScore`/`decrementScore` du rejet `rejectedStaleRevision` — exactement le garde-fou cadré ci-dessus pour l'usage répété rapide. Table Drift `active_manual_score_states` créée avec `CHECK(value >= 0)` (plancher renforcé aussi en base), `schemaVersion` bumpé à 20 avec migration dédiée, table enregistrée dans `_syncableTableNames`. Contrat `watch_bridge_contract` étendu exactement comme cadré (`hasManualScore`/`currentManualScoreValue`/`canDecrementScore`, `fromJson` avec défauts sûrs). Côté montre : `incrementScore`/`decrementScore` avec mise à jour optimiste instantanée, opacité atténuée pendant l'attente, recalage silencieux sur échec/timeout (`requestResync()`, aucun message d'erreur) — conforme au cadrage UX #118.
**Garde-fou à fermer avant merge** : le pivot téléphone n'est **qu'à moitié fait**. L'écran d'exécution (`workout_execution_screen.dart`) **lit** bien `ActiveManualScoreState` en direct (`_syncManualScoreInput`, polling via le ticker 1s existant, ignoré si le champ a le focus) — donc une correction faite depuis la montre remonte bien à l'écran téléphone. Mais l'inverse est manquant : **taper une valeur dans le champ texte du téléphone n'écrit jamais dans `ActiveManualScoreState`** ; le texte tapé n'est consommé qu'au moment de `finishCurrentSet`, en paramètre explicite qui écrase la valeur vivante seulement à cet instant précis. Conséquence concrète : si l'utilisateur corrige le score sur le téléphone puis tape encore une fois sur la montre avant de terminer la série, le tap montre repart de l'**ancienne** valeur persistée (pas de la correction tapée), et à la validation de la série le champ téléphone (dernière source lue) écrase silencieusement ce que la montre vient d'appliquer — un des deux côtés perd sa modification sans avertissement, alors que l'UX #118 attend explicitement que "la correction plus lourde se fait sur le téléphone, source de vérité complète" en cohérence avec ce que voit la montre à ce moment.
**Contrat à fermer pour DevFrontend** : toute édition explicite du champ texte téléphone (`onSubmitted`/`onEditingComplete`/perte de focus) doit **écrire à travers** vers `ActiveManualScoreState` (ex. un `setManualScore(sessionId, position, value)` sur `ActiveWorkoutSessionUseCases`, à ajouter en miroir des deux méthodes existantes, avec le même clamp `[0, +∞)`), pas seulement être lue en fin de série. Cela rend les deux sens de synchronisation (montre→téléphone déjà fait, téléphone→montre à fermer) symétriques et élimine la fenêtre de divergence silencieuse. Sans ce correctif, ne pas considérer le lot #119 comme fonctionnellement complet du point de vue de l'invariant "le téléphone reste toujours la source de vérité complète" posé par UX #118.
---
## Vérification Architect — gap résiduel fermé (2026-07-27, second passage)
Le gap ci-dessus est **résolu dans le worktree actuel** : `ActiveWorkoutSessionUseCases.setManualScore` (`lib/application/use_cases.dart:2730`) existe et applique le même clamp `[0, +∞)` que `increment`/`decrementManualScore`. Côté écran (`lib/presentation/workout_execution_screen.dart`), `_persistManualScoreInputOnce` (ligne 702) écrit à travers vers `setManualScore` à chaque édition explicite validée, appelée par :
- perte de focus (`_handleScoreFocusChange`, ligne 139-140) ;
- `onScoreSubmitted`/`onEditingComplete` câblés sur `_persistManualScoreInput` (lignes 257-259, 309-311) ;
- juste avant `recordCurrentSetResult` dans `_recordAndAdvance` (ligne 1426).
Les deux sens de synchronisation sont désormais symétriques. L'invariant UX #118 ("le téléphone reste la source de vérité complète") est respecté. **Aucune action supplémentaire requise sur #119** ; le lot est fonctionnellement complet.