diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 3f7ba32..1981b6e 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -939,6 +939,15 @@ abstract interface class WorkoutHistoryRepository { abstract interface class WorkoutTelemetryRepository { Future saveSample(WorkoutTelemetrySample sample); Future> listSamples(String sessionId); + Future> listSamplesForScope({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }); Future replaceAggregatesForSession({ required String sessionId, required List aggregates, diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 9ba8abb..e95e7e4 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -1148,6 +1148,7 @@ final class ExerciseUseCases { double? defaultTargetScore, int? defaultTargetScoreTimeMs, bool autoStartNextTimedStep = true, + List businessTypes = const [], List tags = const [], List 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 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, 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 []; 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> 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 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 []; + 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> 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 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 = []; + + 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 _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 _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 = []; + for (final rawSample in rawSamples) { + if (rawSample is! Map) { + continue; + } + final json = Map.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 _telemetryAggregatesFromSamples( List samples, ) { diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index e090f1c..b3572e4 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -21,6 +21,18 @@ enum ExerciseCategory { uncategorized, } +enum BusinessExerciseType { + shoot, + freeThrows, + dribble, + finishing, + conditioning, + defense, + mobility, + highIntensity, + recovery, +} + enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned } enum SetResultStatus { completed, skipped } @@ -333,6 +345,7 @@ final class Exercise { this.defaultTargetScoreTimeMs, this.autoStartNextTimedStep = true, this.category = ExerciseCategory.uncategorized, + List businessTypes = const [], this.isExample = false, List tags = const [], List steps = const [], @@ -347,6 +360,7 @@ final class Exercise { iconMediaId, _resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds), ), + businessTypes = _validatedBusinessExerciseTypes(businessTypes), steps = _validatedExerciseSteps(steps) { _requireAtLeastOneMeasure( hasTime: hasTimeMeasure, @@ -406,6 +420,7 @@ final class Exercise { final int? defaultTargetScoreTimeMs; final bool autoStartNextTimedStep; final ExerciseCategory category; + final List businessTypes; final bool isExample; final List tags; final List steps; @@ -420,6 +435,10 @@ final class Exercise { String? get imageMediaId => imageMediaIds.isEmpty ? null : imageMediaIds.first; + List get effectiveBusinessTypes => businessTypes.isEmpty + ? _businessTypesFromCategory(category) + : businessTypes; + Exercise archive(DateTime now) { return copyWith(archivedAt: now, metadata: metadata.touch(now)); } @@ -444,6 +463,7 @@ final class Exercise { Object? defaultTargetScoreTimeMs = _unchanged, bool? autoStartNextTimedStep, ExerciseCategory? category, + Object? businessTypes = _unchanged, bool? isExample, Object? tags = _unchanged, Object? steps = _unchanged, @@ -496,6 +516,9 @@ final class Exercise { autoStartNextTimedStep: autoStartNextTimedStep ?? this.autoStartNextTimedStep, category: category ?? this.category, + businessTypes: businessTypes == _unchanged + ? this.businessTypes + : businessTypes as List, isExample: isExample ?? this.isExample, tags: tags == _unchanged ? this.tags : tags as List, steps: steps == _unchanged ? this.steps : steps as List, @@ -1675,6 +1698,68 @@ final class WorkoutTelemetryAggregate { 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 points; + final int? minHeartRateBpm; + final int? maxHeartRateBpm; +} + final class WorkoutHistorySetResult { WorkoutHistorySetResult({ required this.metadata, @@ -1924,6 +2009,33 @@ List _validatedExerciseSteps(List steps) { return List.unmodifiable(steps); } +List _validatedBusinessExerciseTypes( + List types, +) { + final unique = []; + for (final type in types) { + if (!unique.contains(type)) { + unique.add(type); + } + } + return List.unmodifiable(unique); +} + +List _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({ required bool hasTime, required bool hasReps, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 6cc29bc..deae302 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -51,7 +51,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 24; + int get schemaVersion => 25; @override MigrationStrategy get migration { @@ -60,6 +60,7 @@ final class AppDatabase extends _$AppDatabase { await migrator.createAll(); await _migrateToSchema15(); await _migrateToSchema16(migrator); + await _migrateToSchema25(); await _createIndexes(); }, onUpgrade: (migrator, from, to) async { @@ -137,6 +138,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 24) { await _migrateToSchema24(migrator); } + if (from < 25) { + await _migrateToSchema25(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -956,6 +960,16 @@ FROM pending_share_actions await migrator.createTable(workoutTelemetryAggregates); } + Future _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 _backfillWorkoutHistorySetSourceExerciseIds() async { await customStatement(r''' UPDATE workout_history_set_results AS result diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 491465c..55b0e09 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -112,7 +112,8 @@ final class DriftExerciseRepository implements ExerciseRepository { ) async { final row = await database .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(exerciseId)], ) .getSingleOrNull(); @@ -123,6 +124,9 @@ final class DriftExerciseRepository implements ExerciseRepository { category: _exerciseCategoryFromDb( row.readNullable('category') ?? 'uncategorized', ), + businessTypes: _decodeBusinessExerciseTypesJson( + row.readNullable('business_types_json'), + ), isExample: (row.readNullable('is_example') ?? 0) == 1, ); } @@ -1484,17 +1488,39 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { Future saveExerciseStepProgressState( domain.ActiveExerciseStepProgressState state, ) async { - await _upsertWithChangeLog( - database: database, - tableName: 'active_exercise_step_progress_states', - entityType: 'ActiveExerciseStepProgressState', - metadata: state.metadata, - write: () => database - .into(database.activeExerciseStepProgressStates) - .insertOnConflictUpdate( - _activeExerciseStepProgressStateCompanion(state), - ), - ); + await database.transaction(() async { + final existing = await _findActiveExerciseStepProgressStateRowByPosition( + database, + state.activeWorkoutSessionId, + state.programIndex, + state.exerciseIndex, + state.setIndex, + ); + 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 @@ -2049,16 +2075,13 @@ final class DriftWorkoutTelemetryRepository final existing = await (database.select( database.workoutTelemetrySamples, )..where((table) => table.id.equals(sample.id))).getSingleOrNull(); - if (existing != null) { + if (existing != null && !sample.capturedAt.isAfter(existing.capturedAt)) { return false; } - final inserted = await database + await database .into(database.workoutTelemetrySamples) - .insert( - _workoutTelemetrySampleCompanion(sample), - mode: InsertMode.insertOrIgnore, - ); - return inserted > 0; + .insertOnConflictUpdate(_workoutTelemetrySampleCompanion(sample)); + return true; } @override @@ -2073,6 +2096,37 @@ final class DriftWorkoutTelemetryRepository return rows.map(_workoutTelemetrySampleFromRow).toList(); } + @override + Future> 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 Future replaceAggregatesForSession({ required String sessionId, @@ -3382,10 +3436,12 @@ String _exerciseImageId(String exerciseId, String mediaId) { final class _ExerciseStarterMetadata { const _ExerciseStarterMetadata({ this.category = domain.ExerciseCategory.uncategorized, + this.businessTypes = const [], this.isExample = false, }); final domain.ExerciseCategory category; + final List businessTypes; final bool isExample; } @@ -3394,10 +3450,12 @@ Future _writeExerciseStarterMetadata( domain.Exercise exercise, ) async { 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), exercise.isExample ? 1 : 0, + _encodeBusinessExerciseTypes(exercise.businessTypes), exercise.metadata.id, ], ); @@ -3764,6 +3822,7 @@ domain.Exercise _exerciseFromRow( defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs, autoStartNextTimedStep: row.autoStartNextTimedStep, category: starterMetadata.category, + businessTypes: starterMetadata.businessTypes, isExample: starterMetadata.isExample, tags: _decodeTags(row.tagsJson), steps: steps, @@ -4374,6 +4433,24 @@ Future _findActiveManualScoreStateRowByPosition( .getSingleOrNull(); } +Future +_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 metadata, domain.EntityMetadata stored, @@ -4517,6 +4594,71 @@ Future _upsertActiveManualScoreState( ); } +Future _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(metadata.id), + Variable(metadata.createdAt.toUtc()), + Variable(metadata.updatedAt.toUtc()), + Variable(_utcOrNull(metadata.deletedAt)), + Variable(metadata.schemaVersion), + Variable(_syncStateToDb(metadata.syncState)), + Variable(metadata.localRevision), + Variable(metadata.originDeviceId), + Variable(metadata.futureOwnerProfileId), + Variable(_utcOrNull(metadata.lastSyncedAt)), + Variable(metadata.remoteRevision), + Variable(state.activeWorkoutSessionId), + Variable(state.programIndex), + Variable(state.exerciseIndex), + Variable(state.setIndex), + Variable(state.currentPassageIndex), + Variable(state.currentStepIndex), + Variable(state.currentStepSnapshotId), + Variable(_exerciseStepProgressStatusToDb(state.status)), + Variable(_utcOrNull(state.startedAt)), + Variable(state.accumulatedMs), + Variable(state.lastTransitionAt.toUtc()), + ], + ); +} + Future _upsertActiveSetTimerState( db.AppDatabase database, 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, - createdAt: values[1] as Value, - updatedAt: values[2] as Value, - deletedAt: values[3] as Value, - schemaVersion: values[4] as Value, - syncState: values[5] as Value, - localRevision: values[6] as Value, - originDeviceId: values[7] as Value, - futureOwnerProfileId: values[8] as Value, - lastSyncedAt: values[9] as Value, - remoteRevision: values[10] as Value, - 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(_utcOrNull(state.startedAt)), - accumulatedMs: Value(state.accumulatedMs), - lastTransitionAt: Value(state.lastTransitionAt.toUtc()), - ); -} - domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow( 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( db.WorkoutTelemetryAggregate row, ) { @@ -5270,6 +5408,12 @@ Map _exercisePayload(domain.Exercise exercise) => { 'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs, 'autoStartNextTimedStep': exercise.autoStartNextTimedStep, 'category': exercise.category.name, + 'businessTypes': exercise.businessTypes + .map(_businessExerciseTypeToDb) + .toList(), + 'effectiveBusinessTypes': exercise.effectiveBusinessTypes + .map(_businessExerciseTypeToDb) + .toList(), 'isExample': exercise.isExample, 'tags': exercise.tags, 'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(), @@ -5549,6 +5693,7 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) { category: _exerciseCategoryFromDb( payload['category'] as String? ?? 'uncategorized', ), + businessTypes: _businessExerciseTypesFromPayload(payload['businessTypes']), isExample: payload['isExample'] as bool? ?? false, tags: _stringListFromPayload(payload['tags']), steps: _stepsFromPayload(payload['steps']), @@ -5920,6 +6065,40 @@ List _decodeTags(String? encoded) { } } +String _encodeBusinessExerciseTypes(List types) { + return jsonEncode(types.map(_businessExerciseTypeToDb).toList()); +} + +List _decodeBusinessExerciseTypesJson( + String? encoded, +) { + if (encoded == null || encoded.trim().isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(encoded); + return _businessExerciseTypesFromPayload(decoded); + } on Object { + return const []; + } +} + +List _businessExerciseTypesFromPayload( + Object? value, +) { + if (value is! List) { + return const []; + } + final output = []; + for (final raw in value.whereType()) { + final type = _businessExerciseTypeFromDbOrNull(raw); + if (type != null && !output.contains(type)) { + output.add(type); + } + } + return List.unmodifiable(output); +} + List _stepsFromPayload(Object? value) { if (value is! List) { return const []; @@ -6158,6 +6337,33 @@ domain.ExerciseCategory _exerciseCategoryFromDb(String 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) { domain.SetResultStatus.completed => 'completed', domain.SetResultStatus.skipped => 'skipped', diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 12e7d24..266e0ed 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -3324,22 +3324,6 @@ void main() { ), ); 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( sampleId: 'sample-2', sessionId: 'session-1', @@ -3348,7 +3332,42 @@ void main() { 7, 28, 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, programIndex: 0, exerciseIndex: 0, @@ -3367,17 +3386,19 @@ void main() { WorkoutTelemetryAggregateScope.set, WorkoutTelemetryAggregateScope.step, ]); - expect(duplicate, isEmpty); + expect(duplicate, isNotEmpty); + expect(olderInSameBucket, isEmpty); expect(repository.samples, hasLength(2)); + expect(repository.samples.first.heartRateBpm, 160); final sessionAggregate = second.singleWhere( (aggregate) => aggregate.scope == WorkoutTelemetryAggregateScope.session, ); expect(sessionAggregate.sampleCount, 2); - expect(sessionAggregate.minHeartRateBpm, 120); - expect(sessionAggregate.averageHeartRateBpm, 135); - expect(sessionAggregate.maxHeartRateBpm, 150); + expect(sessionAggregate.minHeartRateBpm, 150); + expect(sessionAggregate.averageHeartRateBpm, 155); + expect(sessionAggregate.maxHeartRateBpm, 160); expect(sessionAggregate.totalDistanceMeters, 620); expect(sessionAggregate.totalCaloriesKcal, 48); 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( 'WorkoutHistoryUseCases ignores insufficient heart rate summary', () async { @@ -5309,10 +5403,15 @@ final class _FakeWorkoutTelemetryRepository @override Future 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; } - samples.add(sample); + if (index == -1) { + samples.add(sample); + } else { + samples[index] = sample; + } samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt)); return true; } @@ -5324,6 +5423,33 @@ final class _FakeWorkoutTelemetryRepository .toList(growable: false); } + @override + Future> 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 Future replaceAggregatesForSession({ 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( _FakeActiveSessionRepository repository, _FakeClock clock, diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 6350b11..597f620 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -124,6 +124,7 @@ void main() { } 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('workout_templates'), contains('tags_json')); expect( @@ -143,7 +144,55 @@ void main() { await columnNames('workout_telemetry_aggregates'), 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( @@ -165,17 +214,56 @@ void main() { id: 'sample-2', sessionId: 'session-1', capturedAt: DateTime.utc(2026, 7, 28, 10, 1), + programIndex: 0, + exerciseIndex: 1, heartRateBpm: 150, distanceMeters: 620, 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), isFalse); + expect(await telemetryRepository.saveSample(replacement), isTrue); + expect(await telemetryRepository.saveSample(olderReplacement), isFalse); expect(await telemetryRepository.saveSample(second), isTrue); final samples = await telemetryRepository.listSamples('session-1'); 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( sessionId: 'session-1', @@ -484,6 +572,41 @@ CREATE TABLE pending_share_actions ( 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 { final now = DateTime.utc(2026, 7, 22, 10, 45); 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( 'closing a session stores autonomous history rows with set snapshots', () async {