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

7.4 KiB

issueRef, version, updatedBy, updatedAt
issueRef version updatedBy updatedAt
#134 6
kind
user
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.revisionWatchCommandAck.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.