Files
GameTime/.ideai/tickets/134/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

54 lines
7.4 KiB
Markdown

---
issueRef: "#134"
version: 6
updatedBy: {"kind":"user"}
updatedAt: 1785141267072
---
## Cadrage UX (agent UX, 2026-07-27)
**Nature** : bug technique en premier lieu, avec un gap UX secondaire à corriger dans la même passe.
**Localisation** : `watch_app/lib/presentation/watch_session_screen.dart::_handleSecondaryAction` — action `WatchSecondaryAction.skipCurrentSet` (« Passer la série »), confirmée dans `_ConfirmSheet`, envoyée via `WatchSessionViewModel.sendSecondaryAction`.
**Ce qui se passe actuellement** : après confirmation, la commande est envoyée en `unawaited` puis l'écran revient immédiatement sur la vue Séance (`_showSession()`), **sans attendre le résultat**. Que la commande soit acceptée, rejetée (revision périmée, phase non applicable) ou acquittée en no-op, l'utilisateur voit exactement le même retour à l'affichage de la série. C'est cohérent avec le symptôme rapporté : « ça me renvoie sur l'affichage de la série sans rien faire ».
**Diagnostic technique à vérifier par Architect/DevBackend/DevFrontend** :
- pourquoi la commande est rejetée ou sans effet côté téléphone (mismatch `expectedRevision`, phase courante qui n'accepte pas `skipCurrentSet`, message perdu côté Data Layer) ;
- si c'est un problème de routing/state machine, corriger la cause racine en priorité.
**Décision UX à poser (indépendamment du bug)** : le flux ne doit plus être aveugle au résultat. Ne pas naviguer en silence dans tous les cas :
- Garder la navigation immédiate vers l'écran Séance (cohérent avec le pattern existant, pas de nouvel écran d'attente qui bloquerait l'utilisateur).
- Mais si la commande est rejetée/no-op (`ack` reçu ensuite avec statut rejeté), afficher un signal bref et non bloquant sur l'écran Séance — réutiliser le pattern déjà en place pour la perte de connexion (bandeau discret en haut, icône + texte court, disparaît de lui-même) plutôt qu'un dialogue. Libellé proposé : « Série non modifiée » (pas de jargon type "commande rejetée").
- Ne pas introduire de mention "hors ligne"/"mode local" (cohérent avec [[gametime-online-layer-philosophy]] déjà appliqué au reste de la montre).
**Résumé** : corriger la cause technique du rejet/perte de commande est prioritaire ; ajouter le signal d'échec est un complément UX nécessaire pour que l'absence d'effet ne soit plus jamais silencieuse, même si un futur bug similaire survient ailleurs.
---
## Audit technique (agent Architect, 2026-07-27)
**Root cause confirmée dans le code, partagée avec #135** : le refresh périodique côté téléphone bump la revision de projection **sans condition**, même quand rien n'a changé.
- `WatchWearDataLayerAdapter._ensureProjectionRefreshLoop` (`lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart:151-155`) appelle `emitCurrentProjection()` toutes les 2s (`_projectionRefreshInterval`), sans condition.
- `WatchCompanionProjectionUseCases.emitCurrentProjection` (`lib/application/use_cases.dart:3334-3342`) fait `_revision += 1` à **chaque** appel, qu'il y ait eu un changement métier ou non.
- La montre envoie `expectedRevision: value.projection.revision` (`watch_app/lib/application/watch_session_view_model.dart:179`), figé au moment du dernier projection reçue.
- `WatchCompanionCommandHandler._allowsStaleRevision` (`lib/application/use_cases.dart:3467-3470`) n'exempte que `incrementScore`/`decrementScore` du contrôle de revision. `skipCurrentSet` (et tous les autres secondary actions) reste soumis à `command.expectedRevision != projection.revision``WatchCommandAck.rejectedStaleRevision` (`use_cases.dart:3397-3399`).
- Le flux `skipCurrentSet` impose un `_ConfirmSheet` (tap "Passer la série ?" → confirmation), donc un délai utilisateur quasi garanti > 2s : la fenêtre de validité de la revision expire régulièrement avant l'envoi effectif de la commande. C'est structurellement, pas occasionnellement, défaillant.
- Second bug indépendant, confirmé côté UI : même quand l'ack rejeté revient correctement (`WatchSessionViewModel._handleAck` nettoie bien `commandPending` et déclenche `requestResync()`), rien ne l'affiche : `_handleSecondaryAction` (`watch_app/lib/presentation/watch_session_screen.dart:107-110`) fait `unawaited(sendSecondaryAction(action)); _showSession();` sans jamais lire `state.lastAck`. Aucun widget de l'écran Séance ne consulte `lastAck` aujourd'hui — le bandeau "Série non modifiée" demandé par UX n'existe pas encore.
**Contrat technique — DevBackend** :
1. Corriger `emitCurrentProjection()` pour ne bumper `_revision` que si la projection a réellement changé (comparaison hors champs volatils `revision`/`projectedAtEpochMs`, en traitant les champs de timer interpolables — `accumulatedMs`, `referenceEpochMs`, `startedAtEpochMs`, `runState` — comme stables tant que le timer tourne sans transition). Si rien n'a changé, republier/rafraîchir quand même (pour garder le Data Layer vivant) mais **réutiliser la même revision**.
2. Ne pas élargir `_allowsStaleRevision` à `skipCurrentSet` comme raccourci : `_isApplicable` (`use_cases.dart:3434-3465`) garantit déjà que l'action est cohérente avec la phase courante ; le vrai bug est le bump inutile de revision, pas la vérification elle-même.
**Contrat technique — DevFrontend (watch_app)** :
1. `WatchSessionViewModel` : dans `_handleAck`, quand `_isRejected(ack.status)` est vrai pour une commande **secondaire** (pas primaire), exposer un état transitoire consommable par l'écran (ex. un champ `rejectedSecondaryNotice` horodaté dans `WatchSessionUiState`, à côté de `lastAck` déjà existant), auto-effacé après ~2.5s via un `Timer` interne (pattern déjà utilisé pour `_waitingTimer`/`_commandTimeoutTimer`).
2. `watch_session_screen.dart` : ajouter un bandeau réutilisant le pattern visuel de `connectionLost` (icône + texte court, `_SessionMainView` autour de la ligne 299-323) affichant « Série non modifiée » quand ce nouvel état transitoire est actif. Ne pas bloquer la navigation (comportement immédiat vers `_showSession()` conservé, conforme à la décision UX).
3. Point ouvert pour UX : le libellé « Série non modifiée » n'a été spécifié que pour `skipCurrentSet` ; le même mécanisme s'appliquera naturellement aux autres actions secondaires rejetées (`skipCurrentStep`, `skipCurrentPassage`, `finishCurrentSet`, `skipCurrentRest`). Confirmer si un libellé générique convient ou si chaque action a besoin de son propre texte.
**Contrat bridge** : aucune extension nécessaire. `WatchCommandAckEvent`/`WatchCommandAck` (package `watch_bridge_contract`) portent déjà `commandId` + statut suffisant pour ce fix ; c'est un fix côté projection (téléphone) + wiring d'état/UI (montre).
**Testabilité** :
- Fix revision (backend) : 100% sandbox, unit test sur `WatchCompanionProjectionUseCases.emitCurrentProjection()` — appel répété sans changement de session ⇒ revision stable ; mutation de l'état session ⇒ revision bump. Partagé avec #135, un seul test à écrire couvre les deux tickets.
- Bandeau montre : sandbox via `watch_app/test/presentation/watch_session_screen_test.dart` (existe déjà, référence `skipCurrentSet`) — injecter un ack `rejectedStaleRevision` simulé, vérifier l'apparition du bandeau puis sa disparition après le délai.
- Vérification terrain (on-device) recommandée avant clôture : confirmer que le taux de rejet réel chute une fois le fix de revision posé, le round-trip Wear Data Layer réel n'étant pas reproductible en sandbox.