feat(server): implemente synchronisation et serveur avec fixtures (#187)

- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 23:07:31 +02:00
parent d1edb2397e
commit fe3608cc4a
31 changed files with 5056 additions and 394 deletions

View File

@ -6,7 +6,7 @@ import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../domain/domain.dart';
import 'ports.dart';
import 'starter_content/basket_starter_seed_v1.dart';
import 'starter_content/basket_starter_seed_v2.dart';
import 'starter_content/starter_content.dart';
import 'watch_companion_use_cases.dart';
@ -277,9 +277,9 @@ final class SeedStarterContentUseCase {
await contentRepository.insertStarterContent(
buildStarterContent(
exerciseSeeds: basketStarterExerciseSeedsV1,
programSeed: basketStarterProgramSeedV1,
templateSeed: basketStarterWorkoutTemplateSeedV1,
exerciseSeeds: basketStarterExerciseSeedsV2,
programSeed: basketStarterProgramSeedV2,
templateSeed: basketStarterWorkoutTemplateSeedV2,
now: now,
originDeviceId: originDeviceId,
),
@ -5599,6 +5599,7 @@ final class CloseWorkoutSessionUseCase {
final class WorkoutTelemetryUseCases {
const WorkoutTelemetryUseCases({
required this.repository,
this.sessionRepository,
required this.clock,
required this.ids,
});
@ -5606,6 +5607,7 @@ final class WorkoutTelemetryUseCases {
static const persistedSampleInterval = Duration(seconds: 15);
final WorkoutTelemetryRepository repository;
final ActiveSessionRepository? sessionRepository;
final Clock clock;
final IdGenerator ids;
@ -5622,6 +5624,14 @@ final class WorkoutTelemetryUseCases {
return _telemetrySamplesFromHistorySnapshot(history);
}
Future<List<ScopeInstanceDescriptor>> listScopeInstancesForHistory({
required WorkoutHistory history,
required WorkoutTelemetryAggregateScope scope,
}) async {
final samples = await listSamplesForHistory(history);
return _scopeInstancesFromSamples(samples, scope: scope);
}
Future<WorkoutTelemetryGraphSeries> readGraphSeriesForHistory({
required WorkoutHistory history,
required WorkoutTelemetryAggregateScope scope,
@ -5630,6 +5640,56 @@ final class WorkoutTelemetryUseCases {
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
final samples = await _listSamplesForHistoryScope(
history: history,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
);
return _telemetryGraphSeriesFromSamples(
samples,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
);
}
Future<List<ScopeMarker>> readScopeMarkersForHistory({
required WorkoutHistory history,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
final samples = await _listSamplesForHistoryScope(
history: history,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
);
return _scopeMarkersFromSamples(samples, scope: scope);
}
Future<List<WorkoutTelemetrySample>> _listSamplesForHistoryScope({
required WorkoutHistory history,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim();
var samples = const <WorkoutTelemetrySample>[];
@ -5644,53 +5704,128 @@ final class WorkoutTelemetryUseCases {
stepIndex: stepIndex,
);
}
if (samples.isEmpty) {
samples = _telemetrySamplesFromHistorySnapshot(history)
.where(
(sample) => _matchesTelemetryScope(
sample,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
),
)
.toList(growable: false);
if (samples.isNotEmpty) {
return samples;
}
return _telemetryGraphSeriesFromSamples(
samples,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
);
return _telemetrySamplesFromHistorySnapshot(history)
.where(
(sample) => _matchesTelemetryScope(
sample,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
),
)
.toList(growable: false);
}
Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
WatchTelemetrySample sample,
) async {
final domainSample = _telemetrySampleFromWatch(sample);
if (domainSample == null) {
final pendingSample = _telemetryWindowStateFromWatch(sample);
if (pendingSample == null) {
return const [];
}
final inserted = await repository.saveSample(domainSample);
final activeElapsedMs = await _activeElapsedMillisecondsForSample(
pendingSample.sessionId,
pendingSample.latestCapturedAt,
);
if (activeElapsedMs == null) {
return const [];
}
final existing = await repository.findWindowState(pendingSample.sessionId);
if (existing != null &&
!pendingSample.latestCapturedAt.isAfter(existing.latestCapturedAt)) {
return const [];
}
final nextPending = _telemetryWindowStateWithActiveAnchor(
pendingSample,
existing?.windowStartedActiveMs ?? activeElapsedMs,
);
if (existing == null) {
await repository.saveWindowState(nextPending);
return const [];
}
if (activeElapsedMs - existing.windowStartedActiveMs <
persistedSampleInterval.inMilliseconds) {
await repository.saveWindowState(nextPending);
return const [];
}
final consumedSample = existing.toSample(
_telemetrySampleId(existing.sessionId, existing.windowStartedActiveMs),
);
final inserted = await repository.saveSample(consumedSample);
final nextWindowStartedActiveMs =
activeElapsedMs - existing.windowStartedActiveMs >=
persistedSampleInterval.inMilliseconds * 2
? activeElapsedMs
: existing.windowStartedActiveMs +
persistedSampleInterval.inMilliseconds;
await repository.saveWindowState(
_telemetryWindowStateWithActiveAnchor(
pendingSample,
nextWindowStartedActiveMs,
),
);
if (!inserted) {
return const [];
}
final samples = await repository.listSamples(domainSample.sessionId);
final samples = await repository.listSamples(consumedSample.sessionId);
final aggregates = _telemetryAggregatesFromSamples(samples);
await repository.replaceAggregatesForSession(
sessionId: domainSample.sessionId,
sessionId: consumedSample.sessionId,
aggregates: aggregates,
);
return aggregates;
}
WorkoutTelemetrySample? _telemetrySampleFromWatch(
Future<int?> _activeElapsedMillisecondsForSample(
String sessionId,
DateTime capturedAt,
) async {
final repository = sessionRepository;
if (repository == null) {
return capturedAt.millisecondsSinceEpoch;
}
final session = await repository.findById(sessionId);
if (session == null ||
session.status == ActiveWorkoutStatus.completed ||
session.status == ActiveWorkoutStatus.abandoned) {
return null;
}
if (session.status == ActiveWorkoutStatus.paused) {
return null;
}
return session.elapsedActiveMillisecondsAt(capturedAt);
}
ActiveWorkoutTelemetryWindowState _telemetryWindowStateWithActiveAnchor(
ActiveWorkoutTelemetryWindowState state,
int windowStartedActiveMs,
) {
return ActiveWorkoutTelemetryWindowState(
sessionId: state.sessionId,
windowStartedActiveMs: windowStartedActiveMs,
latestCapturedAt: state.latestCapturedAt,
programIndex: state.programIndex,
exerciseIndex: state.exerciseIndex,
setIndex: state.setIndex,
passageIndex: state.passageIndex,
stepIndex: state.stepIndex,
programSnapshotId: state.programSnapshotId,
exerciseSnapshotId: state.exerciseSnapshotId,
stepSnapshotId: state.stepSnapshotId,
heartRateBpm: state.heartRateBpm,
distanceMeters: state.distanceMeters,
caloriesKcal: state.caloriesKcal,
);
}
ActiveWorkoutTelemetryWindowState? _telemetryWindowStateFromWatch(
WatchTelemetrySample sample,
) {
final sessionId = sample.sessionId.trim();
@ -5709,10 +5844,10 @@ final class WorkoutTelemetryUseCases {
isUtc: true,
)
: clock.now();
return WorkoutTelemetrySample(
id: _telemetrySampleId(sample, sessionId, capturedAt),
return ActiveWorkoutTelemetryWindowState(
sessionId: sessionId,
capturedAt: capturedAt,
windowStartedActiveMs: 0,
latestCapturedAt: capturedAt,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
setIndex: sample.setIndex,
@ -5727,16 +5862,8 @@ final class WorkoutTelemetryUseCases {
);
}
String _telemetrySampleId(
WatchTelemetrySample sample,
String sessionId,
DateTime capturedAt,
) {
final bucketMs =
capturedAt.millisecondsSinceEpoch ~/
persistedSampleInterval.inMilliseconds *
persistedSampleInterval.inMilliseconds;
return 'telemetry:$sessionId:$bucketMs';
String _telemetrySampleId(String sessionId, int windowStartedActiveMs) {
return 'telemetry:$sessionId:$windowStartedActiveMs';
}
}
@ -5828,6 +5955,168 @@ WorkoutTelemetryGraphSeries _telemetryGraphSeriesFromSamples(
);
}
List<ScopeInstanceDescriptor> _scopeInstancesFromSamples(
List<WorkoutTelemetrySample> samples, {
required WorkoutTelemetryAggregateScope scope,
}) {
final ordered = samples.toList()
..sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
final firstCapturedAtByKey = <_TelemetryScopeKey, DateTime>{};
for (final sample in ordered) {
final key = _scopeKeyForSample(sample, scope);
if (key == null) {
continue;
}
firstCapturedAtByKey.putIfAbsent(key, () => sample.capturedAt);
}
var ordinal = 0;
return [
for (final entry in firstCapturedAtByKey.entries)
ScopeInstanceDescriptor(
scope: scope,
programIndex: entry.key.programIndex,
exerciseIndex: entry.key.exerciseIndex,
setIndex: entry.key.setIndex,
passageIndex: entry.key.passageIndex,
stepIndex: entry.key.stepIndex,
ordinal: ordinal += 1,
firstCapturedAt: entry.value,
),
];
}
List<ScopeMarker> _scopeMarkersFromSamples(
List<WorkoutTelemetrySample> samples, {
required WorkoutTelemetryAggregateScope scope,
}) {
final ordered = samples.toList()
..sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
if (ordered.isEmpty || scope == WorkoutTelemetryAggregateScope.step) {
return const [];
}
final firstCapturedAt = ordered.first.capturedAt;
final groups = <_TelemetryScopeKey, _ScopeMarkerGroup>{};
for (final sample in ordered) {
final key = _markerChildKeyForSample(sample, parentScope: scope);
if (key == null) {
continue;
}
groups.putIfAbsent(key, () => _ScopeMarkerGroup()).add(sample);
}
var ordinal = 0;
final output = <ScopeMarker>[];
for (final group in groups.values) {
ordinal += 1;
final label = _markerChildLabel(scope, ordinal);
output.add(
ScopeMarker(
elapsedMs: group.firstCapturedAt!
.difference(firstCapturedAt)
.inMilliseconds
.clamp(0, double.infinity)
.toInt(),
label: 'Déb. $label',
boundary: ScopeMarkerBoundary.start,
),
);
output.add(
ScopeMarker(
elapsedMs: group.lastCapturedAt!
.difference(firstCapturedAt)
.inMilliseconds
.clamp(0, double.infinity)
.toInt(),
label: 'Fin $label',
boundary: ScopeMarkerBoundary.end,
),
);
}
return output;
}
_TelemetryScopeKey? _scopeKeyForSample(
WorkoutTelemetrySample sample,
WorkoutTelemetryAggregateScope scope,
) {
return switch (scope) {
WorkoutTelemetryAggregateScope.session => _TelemetryScopeKey(
sessionId: sample.sessionId,
scope: WorkoutTelemetryAggregateScope.session,
),
WorkoutTelemetryAggregateScope.exercise =>
sample.programIndex != null && sample.exerciseIndex != null
? _TelemetryScopeKey(
sessionId: sample.sessionId,
scope: WorkoutTelemetryAggregateScope.exercise,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
)
: null,
WorkoutTelemetryAggregateScope.set =>
sample.programIndex != null &&
sample.exerciseIndex != null &&
sample.setIndex != null
? _TelemetryScopeKey(
sessionId: sample.sessionId,
scope: WorkoutTelemetryAggregateScope.set,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
setIndex: sample.setIndex,
)
: null,
WorkoutTelemetryAggregateScope.step =>
sample.programIndex != null &&
sample.exerciseIndex != null &&
sample.setIndex != null &&
sample.stepIndex != null
? _TelemetryScopeKey(
sessionId: sample.sessionId,
scope: WorkoutTelemetryAggregateScope.step,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
setIndex: sample.setIndex,
passageIndex: sample.passageIndex,
stepIndex: sample.stepIndex,
)
: null,
};
}
_TelemetryScopeKey? _markerChildKeyForSample(
WorkoutTelemetrySample sample, {
required WorkoutTelemetryAggregateScope parentScope,
}) {
return switch (parentScope) {
WorkoutTelemetryAggregateScope.session => _scopeKeyForSample(
sample,
WorkoutTelemetryAggregateScope.exercise,
),
WorkoutTelemetryAggregateScope.exercise => _scopeKeyForSample(
sample,
WorkoutTelemetryAggregateScope.set,
),
WorkoutTelemetryAggregateScope.set => _scopeKeyForSample(
sample,
WorkoutTelemetryAggregateScope.step,
),
WorkoutTelemetryAggregateScope.step => null,
};
}
String _markerChildLabel(
WorkoutTelemetryAggregateScope parentScope,
int ordinal,
) {
return switch (parentScope) {
WorkoutTelemetryAggregateScope.session => 'ex. $ordinal',
WorkoutTelemetryAggregateScope.exercise => 'série $ordinal',
WorkoutTelemetryAggregateScope.set => 'ét. $ordinal',
WorkoutTelemetryAggregateScope.step => '',
};
}
Map<String, Object?> _workoutTelemetrySampleSnapshotJson(
WorkoutTelemetrySample sample,
) {
@ -6028,6 +6317,19 @@ final class _TelemetryScopeKey {
);
}
final class _ScopeMarkerGroup {
DateTime? firstCapturedAt;
DateTime? lastCapturedAt;
void add(WorkoutTelemetrySample sample) {
final capturedAt = sample.capturedAt;
firstCapturedAt ??= capturedAt;
if (lastCapturedAt == null || capturedAt.isAfter(lastCapturedAt!)) {
lastCapturedAt = capturedAt;
}
}
}
final class _TelemetryAggregateBuilder {
_TelemetryAggregateBuilder(this.key);
@ -6099,6 +6401,7 @@ final class ActiveWorkoutSensorState {
this.averageHeartRateBpm,
this.maxHeartRateBpm,
this.latestDistanceMeters,
required this.latestDistanceAvailable,
this.latestCaloriesKcal,
required this.estimatedCaloriesKcal,
});
@ -6112,6 +6415,7 @@ final class ActiveWorkoutSensorState {
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
final double? latestDistanceMeters;
final bool latestDistanceAvailable;
final double? latestCaloriesKcal;
final double estimatedCaloriesKcal;
}
@ -6191,6 +6495,7 @@ final class _ActiveWorkoutSensorAccumulator {
int? _minHeartRateBpm;
int? _maxHeartRateBpm;
double? _latestDistanceMeters;
var _latestDistanceAvailable = false;
double? _latestCaloriesKcal;
ActiveWorkoutSensorState get snapshot {
@ -6207,6 +6512,7 @@ final class _ActiveWorkoutSensorAccumulator {
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: _maxHeartRateBpm,
latestDistanceMeters: _latestDistanceMeters,
latestDistanceAvailable: _latestDistanceAvailable,
latestCaloriesKcal: _latestCaloriesKcal,
estimatedCaloriesKcal: _estimatedCaloriesKcal(
averageHeartRateBpm: averageHeartRateBpm ?? 0,
@ -6253,6 +6559,7 @@ final class _ActiveWorkoutSensorAccumulator {
distanceMeters >= _latestDistanceMeters!)) {
_latestDistanceMeters = distanceMeters;
}
_latestDistanceAvailable = distanceMeters != null;
if (caloriesKcal != null &&
(_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) {
_latestCaloriesKcal = caloriesKcal;