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 { abstract interface class WorkoutTelemetryRepository {
Future<bool> saveSample(WorkoutTelemetrySample sample); Future<bool> saveSample(WorkoutTelemetrySample sample);
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId); 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({ Future<void> replaceAggregatesForSession({
required String sessionId, required String sessionId,
required List<WorkoutTelemetryAggregate> aggregates, required List<WorkoutTelemetryAggregate> aggregates,

View File

@ -1148,6 +1148,7 @@ final class ExerciseUseCases {
double? defaultTargetScore, double? defaultTargetScore,
int? defaultTargetScoreTimeMs, int? defaultTargetScoreTimeMs,
bool autoStartNextTimedStep = true, bool autoStartNextTimedStep = true,
List<BusinessExerciseType> businessTypes = const [],
List<String> tags = const [], List<String> tags = const [],
List<ExerciseStep> steps = const [], List<ExerciseStep> steps = const [],
}) async { }) async {
@ -1181,6 +1182,7 @@ final class ExerciseUseCases {
defaultTargetScore: defaultTargetScore, defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: autoStartNextTimedStep, autoStartNextTimedStep: autoStartNextTimedStep,
businessTypes: businessTypes,
tags: tags, tags: tags,
steps: steps, steps: steps,
); );
@ -1215,6 +1217,7 @@ final class ExerciseUseCases {
Object? defaultTargetScore = _useCaseUnchanged, Object? defaultTargetScore = _useCaseUnchanged,
Object? defaultTargetScoreTimeMs = _useCaseUnchanged, Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
bool? autoStartNextTimedStep, bool? autoStartNextTimedStep,
Object? businessTypes = _useCaseUnchanged,
List<String> tags = const [], List<String> tags = const [],
Object? steps = _useCaseUnchanged, Object? steps = _useCaseUnchanged,
}) async { }) async {
@ -1277,6 +1280,9 @@ final class ExerciseUseCases {
defaultTargetScore: resolvedDefaultTargetScore, defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs, defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
autoStartNextTimedStep: autoStartNextTimedStep, autoStartNextTimedStep: autoStartNextTimedStep,
businessTypes: businessTypes == _useCaseUnchanged
? exercise.businessTypes
: businessTypes as List<BusinessExerciseType>,
tags: tags, tags: tags,
steps: resolvedSteps, steps: resolvedSteps,
); );
@ -5492,6 +5498,9 @@ final class CloseWorkoutSessionUseCase {
sessionId: session.metadata.id, sessionId: session.metadata.id,
scope: WorkoutTelemetryAggregateScope.session, scope: WorkoutTelemetryAggregateScope.session,
); );
final telemetrySamples =
await telemetryRepository?.listSamples(session.metadata.id) ??
const <WorkoutTelemetrySample>[];
final history = WorkoutHistory( final history = WorkoutHistory(
metadata: EntityMetadata( metadata: EntityMetadata(
id: historyId, id: historyId,
@ -5561,6 +5570,9 @@ final class CloseWorkoutSessionUseCase {
}, },
) )
.toList(), .toList(),
'telemetrySamples': telemetrySamples
.map(_workoutTelemetrySampleSnapshotJson)
.toList(),
}), }),
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm, minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm, averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
@ -5582,10 +5594,73 @@ final class WorkoutTelemetryUseCases {
required this.ids, required this.ids,
}); });
static const persistedSampleInterval = Duration(seconds: 15);
final WorkoutTelemetryRepository repository; final WorkoutTelemetryRepository repository;
final Clock clock; final Clock clock;
final IdGenerator ids; 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( Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
WatchTelemetrySample sample, WatchTelemetrySample sample,
) async { ) async {
@ -5626,7 +5701,7 @@ final class WorkoutTelemetryUseCases {
) )
: clock.now(); : clock.now();
return WorkoutTelemetrySample( return WorkoutTelemetrySample(
id: _telemetrySampleId(sample), id: _telemetrySampleId(sample, sessionId, capturedAt),
sessionId: sessionId, sessionId: sessionId,
capturedAt: capturedAt, capturedAt: capturedAt,
programIndex: sample.programIndex, programIndex: sample.programIndex,
@ -5643,12 +5718,200 @@ final class WorkoutTelemetryUseCases {
); );
} }
String _telemetrySampleId(WatchTelemetrySample sample) { String _telemetrySampleId(
final stableId = sample.sampleId?.trim(); WatchTelemetrySample sample,
return stableId == null || stableId.isEmpty ? ids.newId() : stableId; 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<WorkoutTelemetryAggregate> _telemetryAggregatesFromSamples(
List<WorkoutTelemetrySample> samples, List<WorkoutTelemetrySample> samples,
) { ) {

View File

@ -21,6 +21,18 @@ enum ExerciseCategory {
uncategorized, uncategorized,
} }
enum BusinessExerciseType {
shoot,
freeThrows,
dribble,
finishing,
conditioning,
defense,
mobility,
highIntensity,
recovery,
}
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
enum SetResultStatus { completed, skipped } enum SetResultStatus { completed, skipped }
@ -333,6 +345,7 @@ final class Exercise {
this.defaultTargetScoreTimeMs, this.defaultTargetScoreTimeMs,
this.autoStartNextTimedStep = true, this.autoStartNextTimedStep = true,
this.category = ExerciseCategory.uncategorized, this.category = ExerciseCategory.uncategorized,
List<BusinessExerciseType> businessTypes = const [],
this.isExample = false, this.isExample = false,
List<String> tags = const [], List<String> tags = const [],
List<ExerciseStep> steps = const [], List<ExerciseStep> steps = const [],
@ -347,6 +360,7 @@ final class Exercise {
iconMediaId, iconMediaId,
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds), _resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
), ),
businessTypes = _validatedBusinessExerciseTypes(businessTypes),
steps = _validatedExerciseSteps(steps) { steps = _validatedExerciseSteps(steps) {
_requireAtLeastOneMeasure( _requireAtLeastOneMeasure(
hasTime: hasTimeMeasure, hasTime: hasTimeMeasure,
@ -406,6 +420,7 @@ final class Exercise {
final int? defaultTargetScoreTimeMs; final int? defaultTargetScoreTimeMs;
final bool autoStartNextTimedStep; final bool autoStartNextTimedStep;
final ExerciseCategory category; final ExerciseCategory category;
final List<BusinessExerciseType> businessTypes;
final bool isExample; final bool isExample;
final List<String> tags; final List<String> tags;
final List<ExerciseStep> steps; final List<ExerciseStep> steps;
@ -420,6 +435,10 @@ final class Exercise {
String? get imageMediaId => String? get imageMediaId =>
imageMediaIds.isEmpty ? null : imageMediaIds.first; imageMediaIds.isEmpty ? null : imageMediaIds.first;
List<BusinessExerciseType> get effectiveBusinessTypes => businessTypes.isEmpty
? _businessTypesFromCategory(category)
: businessTypes;
Exercise archive(DateTime now) { Exercise archive(DateTime now) {
return copyWith(archivedAt: now, metadata: metadata.touch(now)); return copyWith(archivedAt: now, metadata: metadata.touch(now));
} }
@ -444,6 +463,7 @@ final class Exercise {
Object? defaultTargetScoreTimeMs = _unchanged, Object? defaultTargetScoreTimeMs = _unchanged,
bool? autoStartNextTimedStep, bool? autoStartNextTimedStep,
ExerciseCategory? category, ExerciseCategory? category,
Object? businessTypes = _unchanged,
bool? isExample, bool? isExample,
Object? tags = _unchanged, Object? tags = _unchanged,
Object? steps = _unchanged, Object? steps = _unchanged,
@ -496,6 +516,9 @@ final class Exercise {
autoStartNextTimedStep: autoStartNextTimedStep:
autoStartNextTimedStep ?? this.autoStartNextTimedStep, autoStartNextTimedStep ?? this.autoStartNextTimedStep,
category: category ?? this.category, category: category ?? this.category,
businessTypes: businessTypes == _unchanged
? this.businessTypes
: businessTypes as List<BusinessExerciseType>,
isExample: isExample ?? this.isExample, isExample: isExample ?? this.isExample,
tags: tags == _unchanged ? this.tags : tags as List<String>, tags: tags == _unchanged ? this.tags : tags as List<String>,
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>, steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
@ -1675,6 +1698,68 @@ final class WorkoutTelemetryAggregate {
final double? totalCaloriesKcal; final double? totalCaloriesKcal;
} }
final class WorkoutTelemetryGraphPoint {
WorkoutTelemetryGraphPoint({
required this.capturedAt,
required this.elapsedMs,
this.heartRateBpm,
this.distanceMeters,
this.caloriesKcal,
required this.startsAfterGap,
}) {
_requireNonNegative(elapsedMs, 'Telemetry graph elapsed ms');
_requireNullablePositive(heartRateBpm, 'Heart rate bpm');
_requireNullableNonNegativeDouble(distanceMeters, 'Distance meters');
_requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal');
if (heartRateBpm == null &&
distanceMeters == null &&
caloriesKcal == null) {
throw const DomainException(
'Telemetry graph point must contain at least one metric.',
);
}
}
final DateTime capturedAt;
final int elapsedMs;
final int? heartRateBpm;
final double? distanceMeters;
final double? caloriesKcal;
final bool startsAfterGap;
}
final class WorkoutTelemetryGraphSeries {
WorkoutTelemetryGraphSeries({
required this.scope,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
required this.points,
this.minHeartRateBpm,
this.maxHeartRateBpm,
}) {
_requireNullableNonNegative(programIndex, 'Program index');
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
_requireNullableNonNegative(setIndex, 'Set index');
_requireNullableNonNegative(passageIndex, 'Passage index');
_requireNullableNonNegative(stepIndex, 'Step index');
_requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm');
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
}
final WorkoutTelemetryAggregateScope scope;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
final List<WorkoutTelemetryGraphPoint> points;
final int? minHeartRateBpm;
final int? maxHeartRateBpm;
}
final class WorkoutHistorySetResult { final class WorkoutHistorySetResult {
WorkoutHistorySetResult({ WorkoutHistorySetResult({
required this.metadata, required this.metadata,
@ -1924,6 +2009,33 @@ List<ExerciseStep> _validatedExerciseSteps(List<ExerciseStep> steps) {
return List.unmodifiable(steps); return List.unmodifiable(steps);
} }
List<BusinessExerciseType> _validatedBusinessExerciseTypes(
List<BusinessExerciseType> types,
) {
final unique = <BusinessExerciseType>[];
for (final type in types) {
if (!unique.contains(type)) {
unique.add(type);
}
}
return List.unmodifiable(unique);
}
List<BusinessExerciseType> _businessTypesFromCategory(
ExerciseCategory category,
) {
return switch (category) {
ExerciseCategory.shoot => const [BusinessExerciseType.shoot],
ExerciseCategory.freeThrows => const [BusinessExerciseType.freeThrows],
ExerciseCategory.dribble => const [BusinessExerciseType.dribble],
ExerciseCategory.finishing => const [BusinessExerciseType.finishing],
ExerciseCategory.conditioning => const [BusinessExerciseType.conditioning],
ExerciseCategory.defense => const [BusinessExerciseType.defense],
ExerciseCategory.mobility => const [BusinessExerciseType.mobility],
ExerciseCategory.uncategorized => const [],
};
}
void _requireAtLeastOneMeasure({ void _requireAtLeastOneMeasure({
required bool hasTime, required bool hasTime,
required bool hasReps, required bool hasReps,

View File

@ -51,7 +51,7 @@ final class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 24; int get schemaVersion => 25;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -60,6 +60,7 @@ final class AppDatabase extends _$AppDatabase {
await migrator.createAll(); await migrator.createAll();
await _migrateToSchema15(); await _migrateToSchema15();
await _migrateToSchema16(migrator); await _migrateToSchema16(migrator);
await _migrateToSchema25();
await _createIndexes(); await _createIndexes();
}, },
onUpgrade: (migrator, from, to) async { onUpgrade: (migrator, from, to) async {
@ -137,6 +138,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 24) { if (from < 24) {
await _migrateToSchema24(migrator); await _migrateToSchema24(migrator);
} }
if (from < 25) {
await _migrateToSchema25();
}
await _createIndexes(); await _createIndexes();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@ -956,6 +960,16 @@ FROM pending_share_actions
await migrator.createTable(workoutTelemetryAggregates); await migrator.createTable(workoutTelemetryAggregates);
} }
Future<void> _migrateToSchema25() async {
await _addColumnIfMissing(
tableName: 'exercises',
columnName: 'business_types_json',
definition:
"business_types_json TEXT NOT NULL DEFAULT '[]' "
'CHECK (json_valid(business_types_json))',
);
}
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async { Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
await customStatement(r''' await customStatement(r'''
UPDATE workout_history_set_results AS result UPDATE workout_history_set_results AS result

View File

@ -112,7 +112,8 @@ final class DriftExerciseRepository implements ExerciseRepository {
) async { ) async {
final row = await database final row = await database
.customSelect( .customSelect(
'SELECT category, is_example FROM exercises WHERE id = ? LIMIT 1', 'SELECT category, is_example, business_types_json '
'FROM exercises WHERE id = ? LIMIT 1',
variables: [Variable<String>(exerciseId)], variables: [Variable<String>(exerciseId)],
) )
.getSingleOrNull(); .getSingleOrNull();
@ -123,6 +124,9 @@ final class DriftExerciseRepository implements ExerciseRepository {
category: _exerciseCategoryFromDb( category: _exerciseCategoryFromDb(
row.readNullable<String>('category') ?? 'uncategorized', row.readNullable<String>('category') ?? 'uncategorized',
), ),
businessTypes: _decodeBusinessExerciseTypesJson(
row.readNullable<String>('business_types_json'),
),
isExample: (row.readNullable<int>('is_example') ?? 0) == 1, isExample: (row.readNullable<int>('is_example') ?? 0) == 1,
); );
} }
@ -1484,17 +1488,39 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
Future<void> saveExerciseStepProgressState( Future<void> saveExerciseStepProgressState(
domain.ActiveExerciseStepProgressState state, domain.ActiveExerciseStepProgressState state,
) async { ) async {
await _upsertWithChangeLog( await database.transaction(() async {
database: database, final existing = await _findActiveExerciseStepProgressStateRowByPosition(
tableName: 'active_exercise_step_progress_states', database,
entityType: 'ActiveExerciseStepProgressState', state.activeWorkoutSessionId,
metadata: state.metadata, state.programIndex,
write: () => database state.exerciseIndex,
.into(database.activeExerciseStepProgressStates) state.setIndex,
.insertOnConflictUpdate( );
_activeExerciseStepProgressStateCompanion(state), final effectiveState = existing == null
), ? state
); : state.copyWith(
metadata: _metadataWithStoredIdentity(
state.metadata,
_metadataFromRow(existing),
),
);
final operation = existing == null
? 'insert'
: _operationForExistingMutation(
existingDeletedAt: existing.deletedAt,
deletedAt: effectiveState.metadata.deletedAt,
);
await _upsertActiveExerciseStepProgressState(database, effectiveState);
await _writeChangeLog(
database: database,
entityType: 'ActiveExerciseStepProgressState',
entityId: effectiveState.metadata.id,
operation: operation,
localRevision: effectiveState.metadata.localRevision,
originDeviceId: effectiveState.metadata.originDeviceId,
createdAt: effectiveState.metadata.updatedAt,
);
});
} }
@override @override
@ -2049,16 +2075,13 @@ final class DriftWorkoutTelemetryRepository
final existing = await (database.select( final existing = await (database.select(
database.workoutTelemetrySamples, database.workoutTelemetrySamples,
)..where((table) => table.id.equals(sample.id))).getSingleOrNull(); )..where((table) => table.id.equals(sample.id))).getSingleOrNull();
if (existing != null) { if (existing != null && !sample.capturedAt.isAfter(existing.capturedAt)) {
return false; return false;
} }
final inserted = await database await database
.into(database.workoutTelemetrySamples) .into(database.workoutTelemetrySamples)
.insert( .insertOnConflictUpdate(_workoutTelemetrySampleCompanion(sample));
_workoutTelemetrySampleCompanion(sample), return true;
mode: InsertMode.insertOrIgnore,
);
return inserted > 0;
} }
@override @override
@ -2073,6 +2096,37 @@ final class DriftWorkoutTelemetryRepository
return rows.map(_workoutTelemetrySampleFromRow).toList(); return rows.map(_workoutTelemetrySampleFromRow).toList();
} }
@override
Future<List<domain.WorkoutTelemetrySample>> listSamplesForScope({
required String sessionId,
required domain.WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
final rows =
await (database.select(database.workoutTelemetrySamples)
..where((table) => table.sessionId.equals(sessionId))
..orderBy([(table) => OrderingTerm.asc(table.capturedAt)]))
.get();
return rows
.map(_workoutTelemetrySampleFromRow)
.where(
(sample) => _telemetrySampleMatchesScope(
sample,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
),
)
.toList(growable: false);
}
@override @override
Future<void> replaceAggregatesForSession({ Future<void> replaceAggregatesForSession({
required String sessionId, required String sessionId,
@ -3382,10 +3436,12 @@ String _exerciseImageId(String exerciseId, String mediaId) {
final class _ExerciseStarterMetadata { final class _ExerciseStarterMetadata {
const _ExerciseStarterMetadata({ const _ExerciseStarterMetadata({
this.category = domain.ExerciseCategory.uncategorized, this.category = domain.ExerciseCategory.uncategorized,
this.businessTypes = const [],
this.isExample = false, this.isExample = false,
}); });
final domain.ExerciseCategory category; final domain.ExerciseCategory category;
final List<domain.BusinessExerciseType> businessTypes;
final bool isExample; final bool isExample;
} }
@ -3394,10 +3450,12 @@ Future<void> _writeExerciseStarterMetadata(
domain.Exercise exercise, domain.Exercise exercise,
) async { ) async {
await database.customStatement( await database.customStatement(
'UPDATE exercises SET category = ?, is_example = ? WHERE id = ?', 'UPDATE exercises SET category = ?, is_example = ?, '
'business_types_json = ? WHERE id = ?',
[ [
_exerciseCategoryToDb(exercise.category), _exerciseCategoryToDb(exercise.category),
exercise.isExample ? 1 : 0, exercise.isExample ? 1 : 0,
_encodeBusinessExerciseTypes(exercise.businessTypes),
exercise.metadata.id, exercise.metadata.id,
], ],
); );
@ -3764,6 +3822,7 @@ domain.Exercise _exerciseFromRow(
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
autoStartNextTimedStep: row.autoStartNextTimedStep, autoStartNextTimedStep: row.autoStartNextTimedStep,
category: starterMetadata.category, category: starterMetadata.category,
businessTypes: starterMetadata.businessTypes,
isExample: starterMetadata.isExample, isExample: starterMetadata.isExample,
tags: _decodeTags(row.tagsJson), tags: _decodeTags(row.tagsJson),
steps: steps, steps: steps,
@ -4374,6 +4433,24 @@ Future<db.ActiveManualScoreState?> _findActiveManualScoreStateRowByPosition(
.getSingleOrNull(); .getSingleOrNull();
} }
Future<db.ActiveExerciseStepProgressState?>
_findActiveExerciseStepProgressStateRowByPosition(
db.AppDatabase database,
String sessionId,
int programIndex,
int exerciseIndex,
int setIndex,
) {
return (database.select(database.activeExerciseStepProgressStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex),
))
.getSingleOrNull();
}
domain.EntityMetadata _metadataWithStoredIdentity( domain.EntityMetadata _metadataWithStoredIdentity(
domain.EntityMetadata metadata, domain.EntityMetadata metadata,
domain.EntityMetadata stored, domain.EntityMetadata stored,
@ -4517,6 +4594,71 @@ Future<void> _upsertActiveManualScoreState(
); );
} }
Future<void> _upsertActiveExerciseStepProgressState(
db.AppDatabase database,
domain.ActiveExerciseStepProgressState state,
) async {
final metadata = state.metadata;
await database.customInsert(
'INSERT INTO active_exercise_step_progress_states ('
'id, created_at, updated_at, deleted_at, schema_version, sync_state, '
'local_revision, origin_device_id, future_owner_profile_id, '
'last_synced_at, remote_revision, active_workout_session_id, '
'program_index, exercise_index, set_index, current_passage_index, '
'current_step_index, current_step_snapshot_id, status, started_at, '
'accumulated_ms, last_transition_at'
') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '
'ON CONFLICT(active_workout_session_id, program_index, exercise_index, '
'set_index) DO UPDATE SET '
'id = active_exercise_step_progress_states.id, '
'created_at = active_exercise_step_progress_states.created_at, '
'updated_at = excluded.updated_at, '
'deleted_at = excluded.deleted_at, '
'schema_version = excluded.schema_version, '
'sync_state = excluded.sync_state, '
'local_revision = excluded.local_revision, '
'origin_device_id = excluded.origin_device_id, '
'future_owner_profile_id = excluded.future_owner_profile_id, '
'last_synced_at = excluded.last_synced_at, '
'remote_revision = excluded.remote_revision, '
'active_workout_session_id = excluded.active_workout_session_id, '
'program_index = excluded.program_index, '
'exercise_index = excluded.exercise_index, '
'set_index = excluded.set_index, '
'current_passage_index = excluded.current_passage_index, '
'current_step_index = excluded.current_step_index, '
'current_step_snapshot_id = excluded.current_step_snapshot_id, '
'status = excluded.status, '
'started_at = excluded.started_at, '
'accumulated_ms = excluded.accumulated_ms, '
'last_transition_at = excluded.last_transition_at',
variables: [
Variable<String>(metadata.id),
Variable<DateTime>(metadata.createdAt.toUtc()),
Variable<DateTime>(metadata.updatedAt.toUtc()),
Variable<DateTime>(_utcOrNull(metadata.deletedAt)),
Variable<int>(metadata.schemaVersion),
Variable<String>(_syncStateToDb(metadata.syncState)),
Variable<int>(metadata.localRevision),
Variable<String>(metadata.originDeviceId),
Variable<String>(metadata.futureOwnerProfileId),
Variable<DateTime>(_utcOrNull(metadata.lastSyncedAt)),
Variable<String>(metadata.remoteRevision),
Variable<String>(state.activeWorkoutSessionId),
Variable<int>(state.programIndex),
Variable<int>(state.exerciseIndex),
Variable<int>(state.setIndex),
Variable<int>(state.currentPassageIndex),
Variable<int>(state.currentStepIndex),
Variable<String>(state.currentStepSnapshotId),
Variable<String>(_exerciseStepProgressStatusToDb(state.status)),
Variable<DateTime>(_utcOrNull(state.startedAt)),
Variable<int>(state.accumulatedMs),
Variable<DateTime>(state.lastTransitionAt.toUtc()),
],
);
}
Future<void> _upsertActiveSetTimerState( Future<void> _upsertActiveSetTimerState(
db.AppDatabase database, db.AppDatabase database,
domain.ActiveSetTimerState state, domain.ActiveSetTimerState state,
@ -4729,37 +4871,6 @@ domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
); );
} }
db.ActiveExerciseStepProgressStatesCompanion
_activeExerciseStepProgressStateCompanion(
domain.ActiveExerciseStepProgressState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveExerciseStepProgressStatesCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
programIndex: Value(state.programIndex),
exerciseIndex: Value(state.exerciseIndex),
setIndex: Value(state.setIndex),
currentPassageIndex: Value(state.currentPassageIndex),
currentStepIndex: Value(state.currentStepIndex),
currentStepSnapshotId: Value(state.currentStepSnapshotId),
status: Value(_exerciseStepProgressStatusToDb(state.status)),
startedAt: Value<DateTime?>(_utcOrNull(state.startedAt)),
accumulatedMs: Value(state.accumulatedMs),
lastTransitionAt: Value(state.lastTransitionAt.toUtc()),
);
}
domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow( domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow(
db.ActiveExerciseStepProgressState row, db.ActiveExerciseStepProgressState row,
) { ) {
@ -5080,6 +5191,33 @@ domain.WorkoutTelemetrySample _workoutTelemetrySampleFromRow(
); );
} }
bool _telemetrySampleMatchesScope(
domain.WorkoutTelemetrySample sample, {
required domain.WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) {
return switch (scope) {
domain.WorkoutTelemetryAggregateScope.session => true,
domain.WorkoutTelemetryAggregateScope.exercise =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex,
domain.WorkoutTelemetryAggregateScope.set =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex &&
sample.setIndex == setIndex,
domain.WorkoutTelemetryAggregateScope.step =>
sample.programIndex == programIndex &&
sample.exerciseIndex == exerciseIndex &&
sample.setIndex == setIndex &&
(passageIndex == null || sample.passageIndex == passageIndex) &&
sample.stepIndex == stepIndex,
};
}
domain.WorkoutTelemetryAggregate _workoutTelemetryAggregateFromRow( domain.WorkoutTelemetryAggregate _workoutTelemetryAggregateFromRow(
db.WorkoutTelemetryAggregate row, db.WorkoutTelemetryAggregate row,
) { ) {
@ -5270,6 +5408,12 @@ Map<String, Object?> _exercisePayload(domain.Exercise exercise) => {
'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs, 'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs,
'autoStartNextTimedStep': exercise.autoStartNextTimedStep, 'autoStartNextTimedStep': exercise.autoStartNextTimedStep,
'category': exercise.category.name, 'category': exercise.category.name,
'businessTypes': exercise.businessTypes
.map(_businessExerciseTypeToDb)
.toList(),
'effectiveBusinessTypes': exercise.effectiveBusinessTypes
.map(_businessExerciseTypeToDb)
.toList(),
'isExample': exercise.isExample, 'isExample': exercise.isExample,
'tags': exercise.tags, 'tags': exercise.tags,
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(), 'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
@ -5549,6 +5693,7 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
category: _exerciseCategoryFromDb( category: _exerciseCategoryFromDb(
payload['category'] as String? ?? 'uncategorized', payload['category'] as String? ?? 'uncategorized',
), ),
businessTypes: _businessExerciseTypesFromPayload(payload['businessTypes']),
isExample: payload['isExample'] as bool? ?? false, isExample: payload['isExample'] as bool? ?? false,
tags: _stringListFromPayload(payload['tags']), tags: _stringListFromPayload(payload['tags']),
steps: _stepsFromPayload(payload['steps']), steps: _stepsFromPayload(payload['steps']),
@ -5920,6 +6065,40 @@ List<String> _decodeTags(String? encoded) {
} }
} }
String _encodeBusinessExerciseTypes(List<domain.BusinessExerciseType> types) {
return jsonEncode(types.map(_businessExerciseTypeToDb).toList());
}
List<domain.BusinessExerciseType> _decodeBusinessExerciseTypesJson(
String? encoded,
) {
if (encoded == null || encoded.trim().isEmpty) {
return const [];
}
try {
final decoded = jsonDecode(encoded);
return _businessExerciseTypesFromPayload(decoded);
} on Object {
return const [];
}
}
List<domain.BusinessExerciseType> _businessExerciseTypesFromPayload(
Object? value,
) {
if (value is! List) {
return const [];
}
final output = <domain.BusinessExerciseType>[];
for (final raw in value.whereType<String>()) {
final type = _businessExerciseTypeFromDbOrNull(raw);
if (type != null && !output.contains(type)) {
output.add(type);
}
}
return List.unmodifiable(output);
}
List<domain.ExerciseStep> _stepsFromPayload(Object? value) { List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
if (value is! List) { if (value is! List) {
return const []; return const [];
@ -6158,6 +6337,33 @@ domain.ExerciseCategory _exerciseCategoryFromDb(String value) =>
_ => throw domain.DomainException('Unknown exercise category: $value'), _ => throw domain.DomainException('Unknown exercise category: $value'),
}; };
String _businessExerciseTypeToDb(domain.BusinessExerciseType type) =>
switch (type) {
domain.BusinessExerciseType.shoot => 'shoot',
domain.BusinessExerciseType.freeThrows => 'freeThrows',
domain.BusinessExerciseType.dribble => 'dribble',
domain.BusinessExerciseType.finishing => 'finishing',
domain.BusinessExerciseType.conditioning => 'conditioning',
domain.BusinessExerciseType.defense => 'defense',
domain.BusinessExerciseType.mobility => 'mobility',
domain.BusinessExerciseType.highIntensity => 'highIntensity',
domain.BusinessExerciseType.recovery => 'recovery',
};
domain.BusinessExerciseType? _businessExerciseTypeFromDbOrNull(String value) =>
switch (value) {
'shoot' => domain.BusinessExerciseType.shoot,
'freeThrows' => domain.BusinessExerciseType.freeThrows,
'dribble' => domain.BusinessExerciseType.dribble,
'finishing' => domain.BusinessExerciseType.finishing,
'conditioning' => domain.BusinessExerciseType.conditioning,
'defense' => domain.BusinessExerciseType.defense,
'mobility' => domain.BusinessExerciseType.mobility,
'highIntensity' => domain.BusinessExerciseType.highIntensity,
'recovery' => domain.BusinessExerciseType.recovery,
_ => null,
};
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) { String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
domain.SetResultStatus.completed => 'completed', domain.SetResultStatus.completed => 'completed',
domain.SetResultStatus.skipped => 'skipped', domain.SetResultStatus.skipped => 'skipped',

View File

@ -3324,22 +3324,6 @@ void main() {
), ),
); );
final duplicate = await useCase.recordTelemetrySample( final duplicate = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
1,
).millisecondsSinceEpoch,
heartRateBpm: 160,
distanceMeters: 300,
caloriesKcal: 10,
),
);
final second = await useCase.recordTelemetrySample(
WatchTelemetrySample( WatchTelemetrySample(
sampleId: 'sample-2', sampleId: 'sample-2',
sessionId: 'session-1', sessionId: 'session-1',
@ -3348,7 +3332,42 @@ void main() {
7, 7,
28, 28,
10, 10,
2, 0,
10,
).millisecondsSinceEpoch,
heartRateBpm: 160,
distanceMeters: 530,
caloriesKcal: 45,
),
);
final olderInSameBucket = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-older',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
0,
5,
).millisecondsSinceEpoch,
heartRateBpm: 130,
distanceMeters: 510,
caloriesKcal: 43,
),
);
final second = await useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-3',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
28,
10,
0,
20,
).millisecondsSinceEpoch, ).millisecondsSinceEpoch,
programIndex: 0, programIndex: 0,
exerciseIndex: 0, exerciseIndex: 0,
@ -3367,17 +3386,19 @@ void main() {
WorkoutTelemetryAggregateScope.set, WorkoutTelemetryAggregateScope.set,
WorkoutTelemetryAggregateScope.step, WorkoutTelemetryAggregateScope.step,
]); ]);
expect(duplicate, isEmpty); expect(duplicate, isNotEmpty);
expect(olderInSameBucket, isEmpty);
expect(repository.samples, hasLength(2)); expect(repository.samples, hasLength(2));
expect(repository.samples.first.heartRateBpm, 160);
final sessionAggregate = second.singleWhere( final sessionAggregate = second.singleWhere(
(aggregate) => (aggregate) =>
aggregate.scope == WorkoutTelemetryAggregateScope.session, aggregate.scope == WorkoutTelemetryAggregateScope.session,
); );
expect(sessionAggregate.sampleCount, 2); expect(sessionAggregate.sampleCount, 2);
expect(sessionAggregate.minHeartRateBpm, 120); expect(sessionAggregate.minHeartRateBpm, 150);
expect(sessionAggregate.averageHeartRateBpm, 135); expect(sessionAggregate.averageHeartRateBpm, 155);
expect(sessionAggregate.maxHeartRateBpm, 150); expect(sessionAggregate.maxHeartRateBpm, 160);
expect(sessionAggregate.totalDistanceMeters, 620); expect(sessionAggregate.totalDistanceMeters, 620);
expect(sessionAggregate.totalCaloriesKcal, 48); expect(sessionAggregate.totalCaloriesKcal, 48);
expect( expect(
@ -3394,6 +3415,79 @@ void main() {
}, },
); );
test(
'WorkoutTelemetryUseCases reads graph samples by scope with relative cumulative metrics',
() async {
final repository = _FakeWorkoutTelemetryRepository();
final useCase = WorkoutTelemetryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)),
ids: _FakeIds(),
);
final history = WorkoutHistory(
metadata: _metadata('history-telemetry'),
nameSnapshot: 'Séance',
startedAt: DateTime.utc(2026, 7, 28, 10),
endedAt: DateTime.utc(2026, 7, 28, 11),
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: jsonEncode({
'telemetrySamples': [
{
'id': 'sample-1',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 120,
'distanceMeters': 500,
'caloriesKcal': 42,
},
{
'id': 'sample-2',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10, 3).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 0,
'setIndex': 0,
'stepIndex': 0,
'heartRateBpm': 150,
'distanceMeters': 620,
'caloriesKcal': 48,
},
{
'id': 'sample-other',
'sessionId': 'session-1',
'capturedAt': DateTime.utc(2026, 7, 28, 10, 4).toIso8601String(),
'programIndex': 0,
'exerciseIndex': 1,
'heartRateBpm': 90,
},
],
}),
);
final series = await useCase.readGraphSeriesForHistory(
history: history,
scope: WorkoutTelemetryAggregateScope.step,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
);
expect(series.points, hasLength(2));
expect(series.minHeartRateBpm, 120);
expect(series.maxHeartRateBpm, 150);
expect(series.points.first.distanceMeters, 0);
expect(series.points.last.distanceMeters, 120);
expect(series.points.last.caloriesKcal, 6);
expect(series.points.last.startsAfterGap, isTrue);
},
);
test( test(
'WorkoutHistoryUseCases ignores insufficient heart rate summary', 'WorkoutHistoryUseCases ignores insufficient heart rate summary',
() async { () async {
@ -5309,10 +5403,15 @@ final class _FakeWorkoutTelemetryRepository
@override @override
Future<bool> saveSample(WorkoutTelemetrySample sample) async { Future<bool> saveSample(WorkoutTelemetrySample sample) async {
if (samples.any((existing) => existing.id == sample.id)) { final index = samples.indexWhere((existing) => existing.id == sample.id);
if (index != -1 && !sample.capturedAt.isAfter(samples[index].capturedAt)) {
return false; return false;
} }
samples.add(sample); if (index == -1) {
samples.add(sample);
} else {
samples[index] = sample;
}
samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt)); samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
return true; return true;
} }
@ -5324,6 +5423,33 @@ final class _FakeWorkoutTelemetryRepository
.toList(growable: false); .toList(growable: false);
} }
@override
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
return samples
.where(
(sample) =>
sample.sessionId == sessionId &&
_fakeTelemetrySampleMatchesScope(
sample,
scope: scope,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
passageIndex: passageIndex,
stepIndex: stepIndex,
),
)
.toList(growable: false);
}
@override @override
Future<void> replaceAggregatesForSession({ Future<void> replaceAggregatesForSession({
required String sessionId, required String sessionId,
@ -5369,6 +5495,33 @@ final class _FakeWorkoutTelemetryRepository
} }
} }
bool _fakeTelemetrySampleMatchesScope(
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,
};
}
ActiveWorkoutSessionUseCases _activeUseCase( ActiveWorkoutSessionUseCases _activeUseCase(
_FakeActiveSessionRepository repository, _FakeActiveSessionRepository repository,
_FakeClock clock, _FakeClock clock,

View File

@ -124,6 +124,7 @@ void main() {
} }
expect(await columnNames('exercises'), contains('tags_json')); expect(await columnNames('exercises'), contains('tags_json'));
expect(await columnNames('exercises'), contains('business_types_json'));
expect(await columnNames('programs'), contains('tags_json')); expect(await columnNames('programs'), contains('tags_json'));
expect(await columnNames('workout_templates'), contains('tags_json')); expect(await columnNames('workout_templates'), contains('tags_json'));
expect( expect(
@ -143,7 +144,55 @@ void main() {
await columnNames('workout_telemetry_aggregates'), await columnNames('workout_telemetry_aggregates'),
contains('sample_count'), contains('sample_count'),
); );
expect(database.schemaVersion, 24); expect(database.schemaVersion, 25);
});
test('exercise business types persist with category fallback', () async {
final now = DateTime.utc(2026, 7, 30, 12);
final exercise = Exercise(
metadata: _metadata('exercise-business-types', now),
name: 'Dribble intense',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
businessTypes: const [
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
BusinessExerciseType.dribble,
],
);
await exerciseRepository.save(exercise);
final restoredExercise = await exerciseRepository.findById(
exercise.metadata.id,
);
expect(restoredExercise?.businessTypes, [
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
]);
expect(restoredExercise?.effectiveBusinessTypes, [
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
]);
final legacyExercise = Exercise(
metadata: _metadata('exercise-business-types-legacy', now),
name: 'Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
category: ExerciseCategory.shoot,
);
await exerciseRepository.save(legacyExercise);
final restoredLegacy = await exerciseRepository.findById(
legacyExercise.metadata.id,
);
expect(restoredLegacy?.businessTypes, isEmpty);
expect(restoredLegacy?.effectiveBusinessTypes, [
BusinessExerciseType.shoot,
]);
}); });
test( test(
@ -165,17 +214,56 @@ void main() {
id: 'sample-2', id: 'sample-2',
sessionId: 'session-1', sessionId: 'session-1',
capturedAt: DateTime.utc(2026, 7, 28, 10, 1), capturedAt: DateTime.utc(2026, 7, 28, 10, 1),
programIndex: 0,
exerciseIndex: 1,
heartRateBpm: 150, heartRateBpm: 150,
distanceMeters: 620, distanceMeters: 620,
caloriesKcal: 48, caloriesKcal: 48,
); );
final replacement = WorkoutTelemetrySample(
id: 'sample-1',
sessionId: 'session-1',
capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 10),
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 130,
distanceMeters: 530,
caloriesKcal: 45,
);
final olderReplacement = WorkoutTelemetrySample(
id: 'sample-1',
sessionId: 'session-1',
capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 5),
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
heartRateBpm: 125,
distanceMeters: 510,
caloriesKcal: 43,
);
expect(await telemetryRepository.saveSample(first), isTrue); expect(await telemetryRepository.saveSample(first), isTrue);
expect(await telemetryRepository.saveSample(first), isFalse); expect(await telemetryRepository.saveSample(first), isFalse);
expect(await telemetryRepository.saveSample(replacement), isTrue);
expect(await telemetryRepository.saveSample(olderReplacement), isFalse);
expect(await telemetryRepository.saveSample(second), isTrue); expect(await telemetryRepository.saveSample(second), isTrue);
final samples = await telemetryRepository.listSamples('session-1'); final samples = await telemetryRepository.listSamples('session-1');
expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']); expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']);
expect(samples.first.heartRateBpm, 130);
final stepSamples = await telemetryRepository.listSamplesForScope(
sessionId: 'session-1',
scope: WorkoutTelemetryAggregateScope.step,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
stepIndex: 0,
);
expect(stepSamples, hasLength(1));
expect(stepSamples.single.distanceMeters, 530);
await telemetryRepository.replaceAggregatesForSession( await telemetryRepository.replaceAggregatesForSession(
sessionId: 'session-1', sessionId: 'session-1',
@ -484,6 +572,41 @@ CREATE TABLE pending_share_actions (
expect(payloadsById['template-sync-tags']!['tags'], ['routine']); expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
}); });
test('local sync payload includes exercise business types', () async {
final now = DateTime.utc(2026, 7, 30, 13);
final exercise = Exercise(
metadata: _metadata('exercise-sync-business-types', now),
name: 'Drive',
hasTimeMeasure: true,
hasRepsMeasure: false,
hasScoreMeasure: false,
);
await exerciseRepository.save(exercise);
final updatedExercise = exercise.copyWith(
metadata: _metadata(
'exercise-sync-business-types',
now.add(const Duration(seconds: 1)),
1,
),
businessTypes: const [
BusinessExerciseType.dribble,
BusinessExerciseType.finishing,
],
);
await exerciseRepository.save(updatedExercise);
final changes = await syncChangeRepository.listPendingChanges();
final payloadsById = {
for (final change in changes) change.item.clientId: change.item.payload,
};
expect(payloadsById['exercise-sync-business-types']!['businessTypes'], [
'dribble',
'finishing',
]);
});
test('local sync payload includes full workout history aggregate', () async { test('local sync payload includes full workout history aggregate', () async {
final now = DateTime.utc(2026, 7, 22, 10, 45); final now = DateTime.utc(2026, 7, 22, 10, 45);
await historyRepository.save( await historyRepository.save(
@ -2249,6 +2372,82 @@ CREATE TABLE pending_share_actions (
}, },
); );
test(
'active exercise step progress keeps stored identity when upserted by position',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
await activeRepository.save(
ActiveWorkoutSession(
metadata: _metadata('session-step-progress-upsert', now),
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
),
);
await activeRepository.saveExerciseStepProgressState(
ActiveExerciseStepProgressState(
metadata: _metadata('step-progress-original', now),
activeWorkoutSessionId: 'session-step-progress-upsert',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
currentPassageIndex: 0,
currentStepIndex: 0,
currentStepSnapshotId: 'step-snapshot-1',
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
accumulatedMs: 0,
lastTransitionAt: now,
),
);
final updatedAt = now.add(const Duration(minutes: 1));
await activeRepository.saveExerciseStepProgressState(
ActiveExerciseStepProgressState(
metadata: _metadata('step-progress-new-id', updatedAt, 1),
activeWorkoutSessionId: 'session-step-progress-upsert',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
currentPassageIndex: 0,
currentStepIndex: 1,
currentStepSnapshotId: 'step-snapshot-2',
status: ActiveExerciseStepProgressStatus.stoppedTimer,
accumulatedMs: 30000,
lastTransitionAt: updatedAt,
),
);
final rows = await database
.select(database.activeExerciseStepProgressStates)
.get();
final row =
await (database.select(database.activeExerciseStepProgressStates)
..where((table) => table.id.equals('step-progress-original')))
.getSingle();
final changes =
await (database.select(database.changeLogEntries)..where(
(table) =>
table.entityId.equals('step-progress-original') &
table.operation.equals('update'),
))
.get();
expect(rows, hasLength(1));
expect(row.createdAt.toUtc(), now);
expect(row.currentStepIndex, 1);
expect(row.currentStepSnapshotId, 'step-snapshot-2');
expect(row.status, 'stoppedTimer');
expect(row.accumulatedMs, 30000);
expect(changes.map((change) => change.localRevision), [1]);
},
);
test( test(
'closing a session stores autonomous history rows with set snapshots', 'closing a session stores autonomous history rows with set snapshots',
() async { () async {