feat(core): consolide modele donnees stats par scope, types metier d'exercice et correctif contrainte active_exercise_step_progress_states

Couche domaine/application/infra locale partagee par #179 (stockage stats par etape/serie/exercice/seance), #182 (correctif contrainte UNIQUE active_exercise_step_progress_states) et #183 (modele/persistance types metier d'exercice).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:55:52 +02:00
parent 7b5801f959
commit fdaa518fa9
7 changed files with 1036 additions and 80 deletions

View File

@ -939,6 +939,15 @@ abstract interface class WorkoutHistoryRepository {
abstract interface class WorkoutTelemetryRepository {
Future<bool> saveSample(WorkoutTelemetrySample sample);
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId);
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
});
Future<void> replaceAggregatesForSession({
required String sessionId,
required List<WorkoutTelemetryAggregate> aggregates,

View File

@ -1148,6 +1148,7 @@ final class ExerciseUseCases {
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
bool autoStartNextTimedStep = true,
List<BusinessExerciseType> businessTypes = const [],
List<String> tags = const [],
List<ExerciseStep> steps = const [],
}) async {
@ -1181,6 +1182,7 @@ final class ExerciseUseCases {
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: autoStartNextTimedStep,
businessTypes: businessTypes,
tags: tags,
steps: steps,
);
@ -1215,6 +1217,7 @@ final class ExerciseUseCases {
Object? defaultTargetScore = _useCaseUnchanged,
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
bool? autoStartNextTimedStep,
Object? businessTypes = _useCaseUnchanged,
List<String> tags = const [],
Object? steps = _useCaseUnchanged,
}) async {
@ -1277,6 +1280,9 @@ final class ExerciseUseCases {
defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
autoStartNextTimedStep: autoStartNextTimedStep,
businessTypes: businessTypes == _useCaseUnchanged
? exercise.businessTypes
: businessTypes as List<BusinessExerciseType>,
tags: tags,
steps: resolvedSteps,
);
@ -5492,6 +5498,9 @@ final class CloseWorkoutSessionUseCase {
sessionId: session.metadata.id,
scope: WorkoutTelemetryAggregateScope.session,
);
final telemetrySamples =
await telemetryRepository?.listSamples(session.metadata.id) ??
const <WorkoutTelemetrySample>[];
final history = WorkoutHistory(
metadata: EntityMetadata(
id: historyId,
@ -5561,6 +5570,9 @@ final class CloseWorkoutSessionUseCase {
},
)
.toList(),
'telemetrySamples': telemetrySamples
.map(_workoutTelemetrySampleSnapshotJson)
.toList(),
}),
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
@ -5582,10 +5594,73 @@ final class WorkoutTelemetryUseCases {
required this.ids,
});
static const persistedSampleInterval = Duration(seconds: 15);
final WorkoutTelemetryRepository repository;
final Clock clock;
final IdGenerator ids;
Future<List<WorkoutTelemetrySample>> listSamplesForHistory(
WorkoutHistory history,
) async {
final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim();
if (sourceSessionId != null && sourceSessionId.isNotEmpty) {
final samples = await repository.listSamples(sourceSessionId);
if (samples.isNotEmpty) {
return samples;
}
}
return _telemetrySamplesFromHistorySnapshot(history);
}
Future<WorkoutTelemetryGraphSeries> readGraphSeriesForHistory({
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>[];
if (sourceSessionId != null && sourceSessionId.isNotEmpty) {
samples = await repository.listSamplesForScope(
sessionId: sourceSessionId,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
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);
}
return _telemetryGraphSeriesFromSamples(
samples,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
);
}
Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
WatchTelemetrySample sample,
) async {
@ -5626,7 +5701,7 @@ final class WorkoutTelemetryUseCases {
)
: clock.now();
return WorkoutTelemetrySample(
id: _telemetrySampleId(sample),
id: _telemetrySampleId(sample, sessionId, capturedAt),
sessionId: sessionId,
capturedAt: capturedAt,
programIndex: sample.programIndex,
@ -5643,12 +5718,200 @@ final class WorkoutTelemetryUseCases {
);
}
String _telemetrySampleId(WatchTelemetrySample sample) {
final stableId = sample.sampleId?.trim();
return stableId == null || stableId.isEmpty ? ids.newId() : stableId;
String _telemetrySampleId(
WatchTelemetrySample sample,
String sessionId,
DateTime capturedAt,
) {
final bucketMs =
capturedAt.millisecondsSinceEpoch ~/
persistedSampleInterval.inMilliseconds *
persistedSampleInterval.inMilliseconds;
return 'telemetry:$sessionId:$bucketMs';
}
}
WorkoutTelemetryGraphSeries _telemetryGraphSeriesFromSamples(
List<WorkoutTelemetrySample> samples, {
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) {
final ordered = samples.toList()
..sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
if (ordered.isEmpty) {
return WorkoutTelemetryGraphSeries(
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
points: const [],
);
}
final firstCapturedAt = ordered.first.capturedAt;
DateTime? previousCapturedAt;
double? distanceBaseline;
double? caloriesBaseline;
int? minHeartRateBpm;
int? maxHeartRateBpm;
final points = <WorkoutTelemetryGraphPoint>[];
for (final sample in ordered) {
final heartRate = sample.heartRateBpm;
if (heartRate != null) {
minHeartRateBpm = minHeartRateBpm == null
? heartRate
: (heartRate < minHeartRateBpm ? heartRate : minHeartRateBpm);
maxHeartRateBpm = maxHeartRateBpm == null
? heartRate
: (heartRate > maxHeartRateBpm ? heartRate : maxHeartRateBpm);
}
final distance = sample.distanceMeters;
if (distance != null) {
distanceBaseline ??= distance;
}
final calories = sample.caloriesKcal;
if (calories != null) {
caloriesBaseline ??= calories;
}
points.add(
WorkoutTelemetryGraphPoint(
capturedAt: sample.capturedAt,
elapsedMs: sample.capturedAt.difference(firstCapturedAt).inMilliseconds,
heartRateBpm: heartRate,
distanceMeters: distance == null
? null
: (distance - distanceBaseline!)
.clamp(0, double.infinity)
.toDouble(),
caloriesKcal: calories == null
? null
: (calories - caloriesBaseline!)
.clamp(0, double.infinity)
.toDouble(),
startsAfterGap:
previousCapturedAt != null &&
sample.capturedAt.difference(previousCapturedAt) >
const Duration(minutes: 2),
),
);
previousCapturedAt = sample.capturedAt;
}
return WorkoutTelemetryGraphSeries(
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
points: points,
minHeartRateBpm: minHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
);
}
Map<String, Object?> _workoutTelemetrySampleSnapshotJson(
WorkoutTelemetrySample sample,
) {
return {
'id': sample.id,
'sessionId': sample.sessionId,
'capturedAt': sample.capturedAt.toUtc().toIso8601String(),
'programIndex': sample.programIndex,
'exerciseIndex': sample.exerciseIndex,
'setIndex': sample.setIndex,
'passageIndex': sample.passageIndex,
'stepIndex': sample.stepIndex,
'programSnapshotId': sample.programSnapshotId,
'exerciseSnapshotId': sample.exerciseSnapshotId,
'stepSnapshotId': sample.stepSnapshotId,
'heartRateBpm': sample.heartRateBpm,
'distanceMeters': sample.distanceMeters,
'caloriesKcal': sample.caloriesKcal,
};
}
List<WorkoutTelemetrySample> _telemetrySamplesFromHistorySnapshot(
WorkoutHistory history,
) {
final decoded = jsonDecode(history.historySnapshotJson);
if (decoded is! Map) {
return const [];
}
final rawSamples = decoded['telemetrySamples'];
if (rawSamples is! List) {
return const [];
}
final output = <WorkoutTelemetrySample>[];
for (final rawSample in rawSamples) {
if (rawSample is! Map) {
continue;
}
final json = Map<String, Object?>.from(rawSample);
final id = json['id'] as String?;
final sessionId = json['sessionId'] as String?;
final capturedAt = DateTime.tryParse(json['capturedAt'] as String? ?? '');
if (id == null || sessionId == null || capturedAt == null) {
continue;
}
output.add(
WorkoutTelemetrySample(
id: id,
sessionId: sessionId,
capturedAt: capturedAt,
programIndex: json['programIndex'] as int?,
exerciseIndex: json['exerciseIndex'] as int?,
setIndex: json['setIndex'] as int?,
passageIndex: json['passageIndex'] as int?,
stepIndex: json['stepIndex'] as int?,
programSnapshotId: json['programSnapshotId'] as String?,
exerciseSnapshotId: json['exerciseSnapshotId'] as String?,
stepSnapshotId: json['stepSnapshotId'] as String?,
heartRateBpm: json['heartRateBpm'] as int?,
distanceMeters: (json['distanceMeters'] as num?)?.toDouble(),
caloriesKcal: (json['caloriesKcal'] as num?)?.toDouble(),
),
);
}
return output;
}
bool _matchesTelemetryScope(
WorkoutTelemetrySample sample, {
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) {
return switch (scope) {
WorkoutTelemetryAggregateScope.session => true,
WorkoutTelemetryAggregateScope.exercise =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex,
WorkoutTelemetryAggregateScope.set =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex &&
sample.setIndex == setIndex,
WorkoutTelemetryAggregateScope.step =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex &&
sample.setIndex == setIndex &&
(passageIndex == null || sample.passageIndex == passageIndex) &&
sample.stepIndex == stepIndex,
};
}
List<WorkoutTelemetryAggregate> _telemetryAggregatesFromSamples(
List<WorkoutTelemetrySample> samples,
) {