From ac51c16b5147272d77eca3b46326ebd022f5b605 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 07:54:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(ex=C3=A9cution):=20derni=C3=A8re=20perform?= =?UTF-8?q?ance=20et=20meilleur=20score=20par=20exercice=20(ticket=20#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la migration Drift v17 (colonne sourceExerciseIdSnapshot sur workout_history_set_results et workout_history_step_results, index associés), le port ExercisePerformanceReferenceRepository et son implémentation Drift, et le use case ExercisePerformanceReferenceUseCase (dernière performance + meilleur score par métrique active pour un exercice donné). Validé GO par Main : dart analyze propre (1 lint mineur de style), 178 tests (4 échecs préexistants sans rapport avec ce lot). Co-Authored-By: Claude Opus 4.8 --- lib/application/app_bootstrap.dart | 9 + lib/application/ports.dart | 100 +++++ lib/application/use_cases.dart | 69 ++++ lib/domain/entities.dart | 4 + lib/infrastructure/local/app_database.dart | 92 ++++- lib/infrastructure/local/app_database.g.dart | 178 ++++++++- .../local/drift_repositories.dart | 224 +++++++++++ lib/infrastructure/local/tables.dart | 2 + .../drift_repositories_test.dart | 366 ++++++++++++++++++ test/presentation/home_screen_test.dart | 32 ++ 10 files changed, 1073 insertions(+), 3 deletions(-) diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 643bed8..1c4ea26 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -13,6 +13,7 @@ abstract interface class AppDependencies { ActiveExerciseStepUseCases get activeExerciseStepUseCases; CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase; WorkoutHistoryUseCases get workoutHistoryUseCases; + ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase; SyncUseCases get syncUseCases; ShareUseCases get shareUseCases; } @@ -29,6 +30,7 @@ final class AppBootstrap implements AppDependencies { required this.activeExerciseStepUseCases, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, + required this.exercisePerformanceReferenceUseCase, required this.syncUseCases, required this.shareUseCases, required this.syncGateway, @@ -54,6 +56,8 @@ final class AppBootstrap implements AppDependencies { @override final WorkoutHistoryUseCases workoutHistoryUseCases; @override + final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; + @override final SyncUseCases syncUseCases; @override final ShareUseCases shareUseCases; @@ -77,6 +81,8 @@ final class AppBootstrap implements AppDependencies { final templateRepository = DriftWorkoutTemplateRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database); final historyRepository = DriftWorkoutHistoryRepository(database); + final performanceReferenceRepository = + DriftExercisePerformanceReferenceRepository(database); final ids = LocalIdGenerator(); const clock = SystemClock(); const originDeviceId = 'local-device'; @@ -159,6 +165,9 @@ final class AppBootstrap implements AppDependencies { repository: historyRepository, clock: clock, ), + exercisePerformanceReferenceUseCase: ExercisePerformanceReferenceUseCase( + repository: performanceReferenceRepository, + ), syncUseCases: SyncUseCases( tokenStore: const SecureStorageAuthTokenStore(), remoteSyncApi: remoteSyncApi, diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 807af7e..9a878a6 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -466,6 +466,106 @@ abstract interface class WorkoutHistoryRepository { Future delete(String id, DateTime deletedAt); } +final class ActivePerformanceMeasures { + const ActivePerformanceMeasures({ + required this.timeEnabled, + required this.repsEnabled, + required this.scoreEnabled, + this.scoreInputMode = ScoreInputMode.manual, + }); + + final bool timeEnabled; + final bool repsEnabled; + final bool scoreEnabled; + final ScoreInputMode scoreInputMode; + + bool get hasAny => timeEnabled || repsEnabled || scoreEnabled; +} + +enum PerformanceMetric { score, reps, time } + +final class WorkoutHistorySetPerformance { + const WorkoutHistorySetPerformance({ + required this.workoutHistoryId, + required this.startedAt, + required this.setIndex, + required this.exerciseNameSnapshot, + required this.scoreInputModeSnapshot, + this.actualTimeMs, + this.actualReps, + this.actualScore, + this.actualScoreTimeMs, + this.completedAt, + }); + + final String workoutHistoryId; + final DateTime startedAt; + final int setIndex; + final String exerciseNameSnapshot; + final ScoreInputMode scoreInputModeSnapshot; + final int? actualTimeMs; + final int? actualReps; + final double? actualScore; + final int? actualScoreTimeMs; + final DateTime? completedAt; +} + +final class WorkoutHistoryMetricPerformance { + const WorkoutHistoryMetricPerformance({ + required this.workoutHistoryId, + required this.startedAt, + required this.setIndex, + required this.exerciseNameSnapshot, + required this.metric, + required this.scoreInputModeSnapshot, + this.actualTimeMs, + this.actualReps, + this.actualScore, + this.actualScoreTimeMs, + this.completedAt, + }); + + final String workoutHistoryId; + final DateTime startedAt; + final int setIndex; + final String exerciseNameSnapshot; + final PerformanceMetric metric; + final ScoreInputMode scoreInputModeSnapshot; + final int? actualTimeMs; + final int? actualReps; + final double? actualScore; + final int? actualScoreTimeMs; + final DateTime? completedAt; +} + +final class ExercisePerformanceReference { + const ExercisePerformanceReference({ + required this.hasAnyHistoryForExercise, + this.last, + this.record, + }); + + final bool hasAnyHistoryForExercise; + final WorkoutHistorySetPerformance? last; + final WorkoutHistoryMetricPerformance? record; +} + +abstract interface class ExercisePerformanceReferenceRepository { + Future hasAnyCompletedHistoryForExercise(String exerciseId); + + Future findLatestSetPerformance({ + required String exerciseId, + required ActivePerformanceMeasures activeMeasures, + required int currentSetIndex, + }); + + Future findBestMetricPerformance({ + required String exerciseId, + required PerformanceMetric metric, + required ScoreInputMode scoreInputMode, + }); +} + final class SyncRunSummary { const SyncRunSummary({ required this.pushedChanges, diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 322486f..d1be130 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3329,6 +3329,9 @@ final class CloseWorkoutSessionUseCase { final stepResults = await sessionRepository.listExerciseStepResults( sessionId, ); + final snapshots = _exerciseSnapshotsById( + session.resolvedTemplateSnapshotJson, + ); final historyId = ids.newId(); final historyResults = _historyResultsFromActiveResults( historyId: historyId, @@ -3341,6 +3344,7 @@ final class CloseWorkoutSessionUseCase { final historyStepResults = _historyStepResultsFromActiveResults( historyId: historyId, results: stepResults, + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, now: now, ids: ids, originDeviceId: originDeviceId, @@ -3377,6 +3381,8 @@ final class CloseWorkoutSessionUseCase { 'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name, 'scoreLabelSnapshot': result.scoreLabelSnapshot, 'scoreUnitSnapshot': result.scoreUnitSnapshot, + 'sourceExerciseIdSnapshot': + snapshots[result.exerciseSnapshotId]?.sourceExerciseId, 'status': result.status.name, }, ) @@ -3407,6 +3413,8 @@ final class CloseWorkoutSessionUseCase { 'actualScore': result.actualScore, 'actualScoreTimeMs': result.actualScoreTimeMs, 'note': result.note, + 'sourceExerciseIdSnapshot': + snapshots[result.exerciseSnapshotId]?.sourceExerciseId, }, ) .toList(), @@ -3571,6 +3579,59 @@ void _validateExerciseSteps(List steps) { } } +final class ExercisePerformanceReferenceUseCase { + const ExercisePerformanceReferenceUseCase({required this.repository}); + + final ExercisePerformanceReferenceRepository repository; + + Future getExercisePerformanceReference({ + required String exerciseId, + required ActivePerformanceMeasures activeMeasures, + required int currentSetIndex, + }) async { + final hasAnyHistory = await repository.hasAnyCompletedHistoryForExercise( + exerciseId, + ); + if (!activeMeasures.hasAny) { + return ExercisePerformanceReference( + hasAnyHistoryForExercise: hasAnyHistory, + ); + } + + final last = await repository.findLatestSetPerformance( + exerciseId: exerciseId, + activeMeasures: activeMeasures, + currentSetIndex: currentSetIndex, + ); + final recordMetric = _recordMetricFor(activeMeasures); + final record = recordMetric == null + ? null + : await repository.findBestMetricPerformance( + exerciseId: exerciseId, + metric: recordMetric, + scoreInputMode: activeMeasures.scoreInputMode, + ); + return ExercisePerformanceReference( + hasAnyHistoryForExercise: hasAnyHistory, + last: last, + record: record, + ); + } +} + +PerformanceMetric? _recordMetricFor(ActivePerformanceMeasures measures) { + if (measures.scoreEnabled) { + return PerformanceMetric.score; + } + if (measures.repsEnabled) { + return PerformanceMetric.reps; + } + if (measures.timeEnabled) { + return PerformanceMetric.time; + } + return null; +} + void _validateAuthInput({required String email, required String password}) { final normalizedEmail = email.trim(); final hasBasicEmailShape = RegExp( @@ -4055,6 +4116,7 @@ List _historyResultsFromActiveResults({ result.scoreLabelSnapshot ?? snapshot?.scoreLabelSnapshot, scoreUnitSnapshot: result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot, + sourceExerciseIdSnapshot: snapshot?.sourceExerciseId, startedAt: result.startedAt, completedAt: result.completedAt, status: result.status, @@ -4089,6 +4151,7 @@ Map _exerciseSnapshotsById( exerciseSnapshotId: id, programNameSnapshot: programName, exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id, + sourceExerciseId: exercise['sourceExerciseId'] as String?, timeEnabled: exercise['timeEnabled'] == true, repsEnabled: exercise['repsEnabled'] == true, scoreEnabled: exercise['scoreEnabled'] == true, @@ -4118,6 +4181,7 @@ final class _ResolvedExerciseSnapshot { required this.exerciseSnapshotId, required this.programNameSnapshot, required this.exerciseNameSnapshot, + this.sourceExerciseId, required this.timeEnabled, required this.repsEnabled, required this.scoreEnabled, @@ -4136,6 +4200,7 @@ final class _ResolvedExerciseSnapshot { final String exerciseSnapshotId; final String programNameSnapshot; final String exerciseNameSnapshot; + final String? sourceExerciseId; final bool timeEnabled; final bool repsEnabled; final bool scoreEnabled; @@ -4181,11 +4246,14 @@ List _exerciseStepsFromSnapshot(Object? value) { List _historyStepResultsFromActiveResults({ required String historyId, required List results, + required String resolvedTemplateSnapshotJson, required DateTime now, required IdGenerator ids, required String originDeviceId, }) { + final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson); return results.map((result) { + final snapshot = snapshots[result.exerciseSnapshotId]; return WorkoutHistoryStepResult( metadata: _newMetadata(ids, originDeviceId, now), workoutHistoryId: historyId, @@ -4214,6 +4282,7 @@ List _historyStepResultsFromActiveResults({ actualScore: result.actualScore, actualScoreTimeMs: result.actualScoreTimeMs, note: result.note, + sourceExerciseIdSnapshot: snapshot?.sourceExerciseId, ); }).toList(); } diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index acfd83d..105b2e3 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -1444,6 +1444,7 @@ final class WorkoutHistorySetResult { this.scoreInputModeSnapshot = ScoreInputMode.manual, this.scoreLabelSnapshot, this.scoreUnitSnapshot, + this.sourceExerciseIdSnapshot, this.startedAt, this.completedAt, this.status = SetResultStatus.completed, @@ -1493,6 +1494,7 @@ final class WorkoutHistorySetResult { final ScoreInputMode scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; + final String? sourceExerciseIdSnapshot; final DateTime? startedAt; final DateTime? completedAt; final SetResultStatus status; @@ -1527,6 +1529,7 @@ final class WorkoutHistoryStepResult { this.actualScore, this.actualScoreTimeMs, this.note, + this.sourceExerciseIdSnapshot, }) { _validateExerciseStepResult( programIndex: programIndex, @@ -1577,6 +1580,7 @@ final class WorkoutHistoryStepResult { final double? actualScore; final int? actualScoreTimeMs; final String? note; + final String? sourceExerciseIdSnapshot; } String _nonBlank(String? value, String label) { diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 7f9a2d4..49483f5 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -48,7 +48,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 16; + int get schemaVersion => 17; @override MigrationStrategy get migration { @@ -113,6 +113,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 16) { await _migrateToSchema16(migrator); } + if (from < 17) { + await _migrateToSchema17(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -229,10 +232,24 @@ final class AppDatabase extends _$AppDatabase { 'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id ' 'ON workout_history_set_results (workout_history_id)', ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS ' + 'idx_workout_history_set_results_source_exercise ' + 'ON workout_history_set_results (source_exercise_id_snapshot, ' + 'set_index) WHERE deleted_at IS NULL AND ' + 'source_exercise_id_snapshot IS NOT NULL', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id ' 'ON workout_history_step_results (workout_history_id)', ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS ' + 'idx_workout_history_step_results_source_exercise ' + 'ON workout_history_step_results (source_exercise_id_snapshot, ' + 'set_index, step_index) WHERE deleted_at IS NULL AND ' + 'source_exercise_id_snapshot IS NOT NULL', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_change_log_entity ' 'ON change_log (entity_type, entity_id)', @@ -697,6 +714,79 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states ( ); } + Future _migrateToSchema17() async { + await _addColumnIfMissing( + tableName: 'workout_history_set_results', + columnName: 'source_exercise_id_snapshot', + definition: 'source_exercise_id_snapshot TEXT', + ); + await _addColumnIfMissing( + tableName: 'workout_history_step_results', + columnName: 'source_exercise_id_snapshot', + definition: 'source_exercise_id_snapshot TEXT', + ); + await _backfillWorkoutHistorySetSourceExerciseIds(); + await _backfillWorkoutHistoryStepSourceExerciseIds(); + } + + Future _backfillWorkoutHistorySetSourceExerciseIds() async { + await customStatement(r''' +UPDATE workout_history_set_results AS result +SET source_exercise_id_snapshot = ( + SELECT json_extract(exercise.value, '$.sourceExerciseId') + FROM workout_history AS history, + json_each( + COALESCE( + json_extract( + history.history_snapshot_json, + '$.resolvedTemplateSnapshotJson' + ), + history.history_snapshot_json + ), + '$.programs' + ) AS program, + json_each( + json_extract(program.value, '$.programSnapshotJson'), + '$.exercises' + ) AS exercise + WHERE history.id = result.workout_history_id + AND json_extract(program.value, '$.id') = result.program_snapshot_id + AND json_extract(exercise.value, '$.id') = result.exercise_snapshot_id + LIMIT 1 +) +WHERE result.source_exercise_id_snapshot IS NULL +'''); + } + + Future _backfillWorkoutHistoryStepSourceExerciseIds() async { + await customStatement(r''' +UPDATE workout_history_step_results AS result +SET source_exercise_id_snapshot = ( + SELECT json_extract(exercise.value, '$.sourceExerciseId') + FROM workout_history AS history, + json_each( + COALESCE( + json_extract( + history.history_snapshot_json, + '$.resolvedTemplateSnapshotJson' + ), + history.history_snapshot_json + ), + '$.programs' + ) AS program, + json_each( + json_extract(program.value, '$.programSnapshotJson'), + '$.exercises' + ) AS exercise + WHERE history.id = result.workout_history_id + AND json_extract(program.value, '$.id') = result.program_snapshot_id + AND json_extract(exercise.value, '$.id') = result.exercise_snapshot_id + LIMIT 1 +) +WHERE result.source_exercise_id_snapshot IS NULL +'''); + } + Future _addColumnIfMissing({ required String tableName, required String columnName, diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index bdc0ca2..4fe48c6 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -24020,6 +24020,17 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _sourceExerciseIdSnapshotMeta = + const VerificationMeta('sourceExerciseIdSnapshot'); + @override + late final GeneratedColumn sourceExerciseIdSnapshot = + GeneratedColumn( + 'source_exercise_id_snapshot', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _startedAtMeta = const VerificationMeta( 'startedAt', ); @@ -24087,6 +24098,7 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults actualScoreTimeMs, scoreLabelSnapshot, scoreUnitSnapshot, + sourceExerciseIdSnapshot, startedAt, completedAt, status, @@ -24410,6 +24422,15 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults ), ); } + if (data.containsKey('source_exercise_id_snapshot')) { + context.handle( + _sourceExerciseIdSnapshotMeta, + sourceExerciseIdSnapshot.isAcceptableOrUnknown( + data['source_exercise_id_snapshot']!, + _sourceExerciseIdSnapshotMeta, + ), + ); + } if (data.containsKey('started_at')) { context.handle( _startedAtMeta, @@ -24575,6 +24596,10 @@ class $WorkoutHistorySetResultsTable extends WorkoutHistorySetResults DriftSqlType.string, data['${effectivePrefix}score_unit_snapshot'], ), + sourceExerciseIdSnapshot: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_exercise_id_snapshot'], + ), startedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}started_at'], @@ -24631,6 +24656,7 @@ class WorkoutHistorySetResult extends DataClass final int? actualScoreTimeMs; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; + final String? sourceExerciseIdSnapshot; final DateTime? startedAt; final DateTime? completedAt; final String status; @@ -24668,6 +24694,7 @@ class WorkoutHistorySetResult extends DataClass this.actualScoreTimeMs, this.scoreLabelSnapshot, this.scoreUnitSnapshot, + this.sourceExerciseIdSnapshot, this.startedAt, this.completedAt, required this.status, @@ -24740,6 +24767,11 @@ class WorkoutHistorySetResult extends DataClass if (!nullToAbsent || scoreUnitSnapshot != null) { map['score_unit_snapshot'] = Variable(scoreUnitSnapshot); } + if (!nullToAbsent || sourceExerciseIdSnapshot != null) { + map['source_exercise_id_snapshot'] = Variable( + sourceExerciseIdSnapshot, + ); + } if (!nullToAbsent || startedAt != null) { map['started_at'] = Variable(startedAt); } @@ -24815,6 +24847,9 @@ class WorkoutHistorySetResult extends DataClass scoreUnitSnapshot: scoreUnitSnapshot == null && nullToAbsent ? const Value.absent() : Value(scoreUnitSnapshot), + sourceExerciseIdSnapshot: sourceExerciseIdSnapshot == null && nullToAbsent + ? const Value.absent() + : Value(sourceExerciseIdSnapshot), startedAt: startedAt == null && nullToAbsent ? const Value.absent() : Value(startedAt), @@ -24890,6 +24925,9 @@ class WorkoutHistorySetResult extends DataClass scoreUnitSnapshot: serializer.fromJson( json['scoreUnitSnapshot'], ), + sourceExerciseIdSnapshot: serializer.fromJson( + json['sourceExerciseIdSnapshot'], + ), startedAt: serializer.fromJson(json['startedAt']), completedAt: serializer.fromJson(json['completedAt']), status: serializer.fromJson(json['status']), @@ -24938,6 +24976,9 @@ class WorkoutHistorySetResult extends DataClass 'actualScoreTimeMs': serializer.toJson(actualScoreTimeMs), 'scoreLabelSnapshot': serializer.toJson(scoreLabelSnapshot), 'scoreUnitSnapshot': serializer.toJson(scoreUnitSnapshot), + 'sourceExerciseIdSnapshot': serializer.toJson( + sourceExerciseIdSnapshot, + ), 'startedAt': serializer.toJson(startedAt), 'completedAt': serializer.toJson(completedAt), 'status': serializer.toJson(status), @@ -24978,6 +25019,7 @@ class WorkoutHistorySetResult extends DataClass Value actualScoreTimeMs = const Value.absent(), Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), + Value sourceExerciseIdSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), String? status, @@ -25034,6 +25076,9 @@ class WorkoutHistorySetResult extends DataClass scoreUnitSnapshot: scoreUnitSnapshot.present ? scoreUnitSnapshot.value : this.scoreUnitSnapshot, + sourceExerciseIdSnapshot: sourceExerciseIdSnapshot.present + ? sourceExerciseIdSnapshot.value + : this.sourceExerciseIdSnapshot, startedAt: startedAt.present ? startedAt.value : this.startedAt, completedAt: completedAt.present ? completedAt.value : this.completedAt, status: status ?? this.status, @@ -25129,6 +25174,9 @@ class WorkoutHistorySetResult extends DataClass scoreUnitSnapshot: data.scoreUnitSnapshot.present ? data.scoreUnitSnapshot.value : this.scoreUnitSnapshot, + sourceExerciseIdSnapshot: data.sourceExerciseIdSnapshot.present + ? data.sourceExerciseIdSnapshot.value + : this.sourceExerciseIdSnapshot, startedAt: data.startedAt.present ? data.startedAt.value : this.startedAt, completedAt: data.completedAt.present ? data.completedAt.value @@ -25173,6 +25221,7 @@ class WorkoutHistorySetResult extends DataClass ..write('actualScoreTimeMs: $actualScoreTimeMs, ') ..write('scoreLabelSnapshot: $scoreLabelSnapshot, ') ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') + ..write('sourceExerciseIdSnapshot: $sourceExerciseIdSnapshot, ') ..write('startedAt: $startedAt, ') ..write('completedAt: $completedAt, ') ..write('status: $status') @@ -25215,6 +25264,7 @@ class WorkoutHistorySetResult extends DataClass actualScoreTimeMs, scoreLabelSnapshot, scoreUnitSnapshot, + sourceExerciseIdSnapshot, startedAt, completedAt, status, @@ -25256,6 +25306,7 @@ class WorkoutHistorySetResult extends DataClass other.actualScoreTimeMs == this.actualScoreTimeMs && other.scoreLabelSnapshot == this.scoreLabelSnapshot && other.scoreUnitSnapshot == this.scoreUnitSnapshot && + other.sourceExerciseIdSnapshot == this.sourceExerciseIdSnapshot && other.startedAt == this.startedAt && other.completedAt == this.completedAt && other.status == this.status); @@ -25296,6 +25347,7 @@ class WorkoutHistorySetResultsCompanion final Value actualScoreTimeMs; final Value scoreLabelSnapshot; final Value scoreUnitSnapshot; + final Value sourceExerciseIdSnapshot; final Value startedAt; final Value completedAt; final Value status; @@ -25334,6 +25386,7 @@ class WorkoutHistorySetResultsCompanion this.actualScoreTimeMs = const Value.absent(), this.scoreLabelSnapshot = const Value.absent(), this.scoreUnitSnapshot = const Value.absent(), + this.sourceExerciseIdSnapshot = const Value.absent(), this.startedAt = const Value.absent(), this.completedAt = const Value.absent(), this.status = const Value.absent(), @@ -25373,6 +25426,7 @@ class WorkoutHistorySetResultsCompanion this.actualScoreTimeMs = const Value.absent(), this.scoreLabelSnapshot = const Value.absent(), this.scoreUnitSnapshot = const Value.absent(), + this.sourceExerciseIdSnapshot = const Value.absent(), this.startedAt = const Value.absent(), this.completedAt = const Value.absent(), this.status = const Value.absent(), @@ -25428,6 +25482,7 @@ class WorkoutHistorySetResultsCompanion Expression? actualScoreTimeMs, Expression? scoreLabelSnapshot, Expression? scoreUnitSnapshot, + Expression? sourceExerciseIdSnapshot, Expression? startedAt, Expression? completedAt, Expression? status, @@ -25480,6 +25535,8 @@ class WorkoutHistorySetResultsCompanion if (scoreLabelSnapshot != null) 'score_label_snapshot': scoreLabelSnapshot, if (scoreUnitSnapshot != null) 'score_unit_snapshot': scoreUnitSnapshot, + if (sourceExerciseIdSnapshot != null) + 'source_exercise_id_snapshot': sourceExerciseIdSnapshot, if (startedAt != null) 'started_at': startedAt, if (completedAt != null) 'completed_at': completedAt, if (status != null) 'status': status, @@ -25521,6 +25578,7 @@ class WorkoutHistorySetResultsCompanion Value? actualScoreTimeMs, Value? scoreLabelSnapshot, Value? scoreUnitSnapshot, + Value? sourceExerciseIdSnapshot, Value? startedAt, Value? completedAt, Value? status, @@ -25563,6 +25621,8 @@ class WorkoutHistorySetResultsCompanion actualScoreTimeMs: actualScoreTimeMs ?? this.actualScoreTimeMs, scoreLabelSnapshot: scoreLabelSnapshot ?? this.scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot ?? this.scoreUnitSnapshot, + sourceExerciseIdSnapshot: + sourceExerciseIdSnapshot ?? this.sourceExerciseIdSnapshot, startedAt: startedAt ?? this.startedAt, completedAt: completedAt ?? this.completedAt, status: status ?? this.status, @@ -25688,6 +25748,11 @@ class WorkoutHistorySetResultsCompanion if (scoreUnitSnapshot.present) { map['score_unit_snapshot'] = Variable(scoreUnitSnapshot.value); } + if (sourceExerciseIdSnapshot.present) { + map['source_exercise_id_snapshot'] = Variable( + sourceExerciseIdSnapshot.value, + ); + } if (startedAt.present) { map['started_at'] = Variable(startedAt.value); } @@ -25739,6 +25804,7 @@ class WorkoutHistorySetResultsCompanion ..write('actualScoreTimeMs: $actualScoreTimeMs, ') ..write('scoreLabelSnapshot: $scoreLabelSnapshot, ') ..write('scoreUnitSnapshot: $scoreUnitSnapshot, ') + ..write('sourceExerciseIdSnapshot: $sourceExerciseIdSnapshot, ') ..write('startedAt: $startedAt, ') ..write('completedAt: $completedAt, ') ..write('status: $status, ') @@ -26171,6 +26237,17 @@ class $WorkoutHistoryStepResultsTable extends WorkoutHistoryStepResults type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _sourceExerciseIdSnapshotMeta = + const VerificationMeta('sourceExerciseIdSnapshot'); + @override + late final GeneratedColumn sourceExerciseIdSnapshot = + GeneratedColumn( + 'source_exercise_id_snapshot', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -26210,6 +26287,7 @@ class $WorkoutHistoryStepResultsTable extends WorkoutHistoryStepResults actualScore, actualScoreTimeMs, note, + sourceExerciseIdSnapshot, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -26560,6 +26638,15 @@ class $WorkoutHistoryStepResultsTable extends WorkoutHistoryStepResults note.isAcceptableOrUnknown(data['note']!, _noteMeta), ); } + if (data.containsKey('source_exercise_id_snapshot')) { + context.handle( + _sourceExerciseIdSnapshotMeta, + sourceExerciseIdSnapshot.isAcceptableOrUnknown( + data['source_exercise_id_snapshot']!, + _sourceExerciseIdSnapshotMeta, + ), + ); + } return context; } @@ -26720,6 +26807,10 @@ class $WorkoutHistoryStepResultsTable extends WorkoutHistoryStepResults DriftSqlType.string, data['${effectivePrefix}note'], ), + sourceExerciseIdSnapshot: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_exercise_id_snapshot'], + ), ); } @@ -26768,6 +26859,7 @@ class WorkoutHistoryStepResult extends DataClass final double? actualScore; final int? actualScoreTimeMs; final String? note; + final String? sourceExerciseIdSnapshot; const WorkoutHistoryStepResult({ required this.id, required this.createdAt, @@ -26806,6 +26898,7 @@ class WorkoutHistoryStepResult extends DataClass this.actualScore, this.actualScoreTimeMs, this.note, + this.sourceExerciseIdSnapshot, }); @override Map toColumns(bool nullToAbsent) { @@ -26883,6 +26976,11 @@ class WorkoutHistoryStepResult extends DataClass if (!nullToAbsent || note != null) { map['note'] = Variable(note); } + if (!nullToAbsent || sourceExerciseIdSnapshot != null) { + map['source_exercise_id_snapshot'] = Variable( + sourceExerciseIdSnapshot, + ); + } return map; } @@ -26956,6 +27054,9 @@ class WorkoutHistoryStepResult extends DataClass ? const Value.absent() : Value(actualScoreTimeMs), note: note == null && nullToAbsent ? const Value.absent() : Value(note), + sourceExerciseIdSnapshot: sourceExerciseIdSnapshot == null && nullToAbsent + ? const Value.absent() + : Value(sourceExerciseIdSnapshot), ); } @@ -27018,6 +27119,9 @@ class WorkoutHistoryStepResult extends DataClass actualScore: serializer.fromJson(json['actualScore']), actualScoreTimeMs: serializer.fromJson(json['actualScoreTimeMs']), note: serializer.fromJson(json['note']), + sourceExerciseIdSnapshot: serializer.fromJson( + json['sourceExerciseIdSnapshot'], + ), ); } @override @@ -27065,6 +27169,9 @@ class WorkoutHistoryStepResult extends DataClass 'actualScore': serializer.toJson(actualScore), 'actualScoreTimeMs': serializer.toJson(actualScoreTimeMs), 'note': serializer.toJson(note), + 'sourceExerciseIdSnapshot': serializer.toJson( + sourceExerciseIdSnapshot, + ), }; } @@ -27106,6 +27213,7 @@ class WorkoutHistoryStepResult extends DataClass Value actualScore = const Value.absent(), Value actualScoreTimeMs = const Value.absent(), Value note = const Value.absent(), + Value sourceExerciseIdSnapshot = const Value.absent(), }) => WorkoutHistoryStepResult( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -27160,6 +27268,9 @@ class WorkoutHistoryStepResult extends DataClass ? actualScoreTimeMs.value : this.actualScoreTimeMs, note: note.present ? note.value : this.note, + sourceExerciseIdSnapshot: sourceExerciseIdSnapshot.present + ? sourceExerciseIdSnapshot.value + : this.sourceExerciseIdSnapshot, ); WorkoutHistoryStepResult copyWithCompanion( WorkoutHistoryStepResultsCompanion data, @@ -27256,6 +27367,9 @@ class WorkoutHistoryStepResult extends DataClass ? data.actualScoreTimeMs.value : this.actualScoreTimeMs, note: data.note.present ? data.note.value : this.note, + sourceExerciseIdSnapshot: data.sourceExerciseIdSnapshot.present + ? data.sourceExerciseIdSnapshot.value + : this.sourceExerciseIdSnapshot, ); } @@ -27298,7 +27412,8 @@ class WorkoutHistoryStepResult extends DataClass ..write('actualReps: $actualReps, ') ..write('actualScore: $actualScore, ') ..write('actualScoreTimeMs: $actualScoreTimeMs, ') - ..write('note: $note') + ..write('note: $note, ') + ..write('sourceExerciseIdSnapshot: $sourceExerciseIdSnapshot') ..write(')')) .toString(); } @@ -27342,6 +27457,7 @@ class WorkoutHistoryStepResult extends DataClass actualScore, actualScoreTimeMs, note, + sourceExerciseIdSnapshot, ]); @override bool operator ==(Object other) => @@ -27383,7 +27499,8 @@ class WorkoutHistoryStepResult extends DataClass other.actualReps == this.actualReps && other.actualScore == this.actualScore && other.actualScoreTimeMs == this.actualScoreTimeMs && - other.note == this.note); + other.note == this.note && + other.sourceExerciseIdSnapshot == this.sourceExerciseIdSnapshot); } class WorkoutHistoryStepResultsCompanion @@ -27425,6 +27542,7 @@ class WorkoutHistoryStepResultsCompanion final Value actualScore; final Value actualScoreTimeMs; final Value note; + final Value sourceExerciseIdSnapshot; final Value rowid; const WorkoutHistoryStepResultsCompanion({ this.id = const Value.absent(), @@ -27464,6 +27582,7 @@ class WorkoutHistoryStepResultsCompanion this.actualScore = const Value.absent(), this.actualScoreTimeMs = const Value.absent(), this.note = const Value.absent(), + this.sourceExerciseIdSnapshot = const Value.absent(), this.rowid = const Value.absent(), }); WorkoutHistoryStepResultsCompanion.insert({ @@ -27504,6 +27623,7 @@ class WorkoutHistoryStepResultsCompanion this.actualScore = const Value.absent(), this.actualScoreTimeMs = const Value.absent(), this.note = const Value.absent(), + this.sourceExerciseIdSnapshot = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -27563,6 +27683,7 @@ class WorkoutHistoryStepResultsCompanion Expression? actualScore, Expression? actualScoreTimeMs, Expression? note, + Expression? sourceExerciseIdSnapshot, Expression? rowid, }) { return RawValuesInsertable({ @@ -27610,6 +27731,8 @@ class WorkoutHistoryStepResultsCompanion if (actualScore != null) 'actual_score': actualScore, if (actualScoreTimeMs != null) 'actual_score_time_ms': actualScoreTimeMs, if (note != null) 'note': note, + if (sourceExerciseIdSnapshot != null) + 'source_exercise_id_snapshot': sourceExerciseIdSnapshot, if (rowid != null) 'rowid': rowid, }); } @@ -27652,6 +27775,7 @@ class WorkoutHistoryStepResultsCompanion Value? actualScore, Value? actualScoreTimeMs, Value? note, + Value? sourceExerciseIdSnapshot, Value? rowid, }) { return WorkoutHistoryStepResultsCompanion( @@ -27694,6 +27818,8 @@ class WorkoutHistoryStepResultsCompanion actualScore: actualScore ?? this.actualScore, actualScoreTimeMs: actualScoreTimeMs ?? this.actualScoreTimeMs, note: note ?? this.note, + sourceExerciseIdSnapshot: + sourceExerciseIdSnapshot ?? this.sourceExerciseIdSnapshot, rowid: rowid ?? this.rowid, ); } @@ -27820,6 +27946,11 @@ class WorkoutHistoryStepResultsCompanion if (note.present) { map['note'] = Variable(note.value); } + if (sourceExerciseIdSnapshot.present) { + map['source_exercise_id_snapshot'] = Variable( + sourceExerciseIdSnapshot.value, + ); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -27866,6 +27997,7 @@ class WorkoutHistoryStepResultsCompanion ..write('actualScore: $actualScore, ') ..write('actualScoreTimeMs: $actualScoreTimeMs, ') ..write('note: $note, ') + ..write('sourceExerciseIdSnapshot: $sourceExerciseIdSnapshot, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -45147,6 +45279,7 @@ typedef $$WorkoutHistorySetResultsTableCreateCompanionBuilder = Value actualScoreTimeMs, Value scoreLabelSnapshot, Value scoreUnitSnapshot, + Value sourceExerciseIdSnapshot, Value startedAt, Value completedAt, Value status, @@ -45187,6 +45320,7 @@ typedef $$WorkoutHistorySetResultsTableUpdateCompanionBuilder = Value actualScoreTimeMs, Value scoreLabelSnapshot, Value scoreUnitSnapshot, + Value sourceExerciseIdSnapshot, Value startedAt, Value completedAt, Value status, @@ -45395,6 +45529,11 @@ class $$WorkoutHistorySetResultsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get sourceExerciseIdSnapshot => $composableBuilder( + column: $table.sourceExerciseIdSnapshot, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get startedAt => $composableBuilder( column: $table.startedAt, builder: (column) => ColumnFilters(column), @@ -45603,6 +45742,11 @@ class $$WorkoutHistorySetResultsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get sourceExerciseIdSnapshot => $composableBuilder( + column: $table.sourceExerciseIdSnapshot, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get startedAt => $composableBuilder( column: $table.startedAt, builder: (column) => ColumnOrderings(column), @@ -45799,6 +45943,11 @@ class $$WorkoutHistorySetResultsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get sourceExerciseIdSnapshot => $composableBuilder( + column: $table.sourceExerciseIdSnapshot, + builder: (column) => column, + ); + GeneratedColumn get startedAt => $composableBuilder(column: $table.startedAt, builder: (column) => column); @@ -45906,6 +46055,7 @@ class $$WorkoutHistorySetResultsTableTableManager Value actualScoreTimeMs = const Value.absent(), Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), + Value sourceExerciseIdSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), Value status = const Value.absent(), @@ -45944,6 +46094,7 @@ class $$WorkoutHistorySetResultsTableTableManager actualScoreTimeMs: actualScoreTimeMs, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, + sourceExerciseIdSnapshot: sourceExerciseIdSnapshot, startedAt: startedAt, completedAt: completedAt, status: status, @@ -45984,6 +46135,7 @@ class $$WorkoutHistorySetResultsTableTableManager Value actualScoreTimeMs = const Value.absent(), Value scoreLabelSnapshot = const Value.absent(), Value scoreUnitSnapshot = const Value.absent(), + Value sourceExerciseIdSnapshot = const Value.absent(), Value startedAt = const Value.absent(), Value completedAt = const Value.absent(), Value status = const Value.absent(), @@ -46022,6 +46174,7 @@ class $$WorkoutHistorySetResultsTableTableManager actualScoreTimeMs: actualScoreTimeMs, scoreLabelSnapshot: scoreLabelSnapshot, scoreUnitSnapshot: scoreUnitSnapshot, + sourceExerciseIdSnapshot: sourceExerciseIdSnapshot, startedAt: startedAt, completedAt: completedAt, status: status, @@ -46135,6 +46288,7 @@ typedef $$WorkoutHistoryStepResultsTableCreateCompanionBuilder = Value actualScore, Value actualScoreTimeMs, Value note, + Value sourceExerciseIdSnapshot, Value rowid, }); typedef $$WorkoutHistoryStepResultsTableUpdateCompanionBuilder = @@ -46176,6 +46330,7 @@ typedef $$WorkoutHistoryStepResultsTableUpdateCompanionBuilder = Value actualScore, Value actualScoreTimeMs, Value note, + Value sourceExerciseIdSnapshot, Value rowid, }); @@ -46401,6 +46556,11 @@ class $$WorkoutHistoryStepResultsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get sourceExerciseIdSnapshot => $composableBuilder( + column: $table.sourceExerciseIdSnapshot, + builder: (column) => ColumnFilters(column), + ); + $$WorkoutHistoriesTableFilterComposer get workoutHistoryId { final $$WorkoutHistoriesTableFilterComposer composer = $composerBuilder( composer: this, @@ -46614,6 +46774,11 @@ class $$WorkoutHistoryStepResultsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get sourceExerciseIdSnapshot => $composableBuilder( + column: $table.sourceExerciseIdSnapshot, + builder: (column) => ColumnOrderings(column), + ); + $$WorkoutHistoriesTableOrderingComposer get workoutHistoryId { final $$WorkoutHistoriesTableOrderingComposer composer = $composerBuilder( composer: this, @@ -46807,6 +46972,11 @@ class $$WorkoutHistoryStepResultsTableAnnotationComposer GeneratedColumn get note => $composableBuilder(column: $table.note, builder: (column) => column); + GeneratedColumn get sourceExerciseIdSnapshot => $composableBuilder( + column: $table.sourceExerciseIdSnapshot, + builder: (column) => column, + ); + $$WorkoutHistoriesTableAnnotationComposer get workoutHistoryId { final $$WorkoutHistoriesTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -46910,6 +47080,7 @@ class $$WorkoutHistoryStepResultsTableTableManager Value actualScore = const Value.absent(), Value actualScoreTimeMs = const Value.absent(), Value note = const Value.absent(), + Value sourceExerciseIdSnapshot = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistoryStepResultsCompanion( id: id, @@ -46949,6 +47120,7 @@ class $$WorkoutHistoryStepResultsTableTableManager actualScore: actualScore, actualScoreTimeMs: actualScoreTimeMs, note: note, + sourceExerciseIdSnapshot: sourceExerciseIdSnapshot, rowid: rowid, ), createCompanionCallback: @@ -46990,6 +47162,7 @@ class $$WorkoutHistoryStepResultsTableTableManager Value actualScore = const Value.absent(), Value actualScoreTimeMs = const Value.absent(), Value note = const Value.absent(), + Value sourceExerciseIdSnapshot = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistoryStepResultsCompanion.insert( id: id, @@ -47029,6 +47202,7 @@ class $$WorkoutHistoryStepResultsTableTableManager actualScore: actualScore, actualScoreTimeMs: actualScoreTimeMs, note: note, + sourceExerciseIdSnapshot: sourceExerciseIdSnapshot, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index be0e66e..21ae6d3 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -1602,6 +1602,138 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { } } +final class DriftExercisePerformanceReferenceRepository + implements ExercisePerformanceReferenceRepository { + const DriftExercisePerformanceReferenceRepository(this.database); + + final db.AppDatabase database; + + @override + Future hasAnyCompletedHistoryForExercise(String exerciseId) async { + final row = await database + .customSelect( + 'SELECT 1 FROM workout_history_set_results AS result ' + 'INNER JOIN workout_history AS history ' + 'ON history.id = result.workout_history_id ' + 'WHERE result.deleted_at IS NULL ' + 'AND history.deleted_at IS NULL ' + 'AND history.completed = 1 ' + 'AND result.status = ? ' + 'AND result.source_exercise_id_snapshot = ? ' + 'LIMIT 1', + variables: [ + const Variable('completed'), + Variable(exerciseId), + ], + ) + .getSingleOrNull(); + return row != null; + } + + @override + Future findLatestSetPerformance({ + required String exerciseId, + required ActivePerformanceMeasures activeMeasures, + required int currentSetIndex, + }) async { + final valuePredicate = _activeMeasuresPredicate(activeMeasures); + if (valuePredicate == null) { + return null; + } + final row = await database + .customSelect( + ''' +WITH candidates AS ( + SELECT + result.workout_history_id, + history.started_at AS history_started_at, + result.set_index, + result.exercise_name_snapshot, + result.score_input_mode_snapshot, + result.actual_time_ms, + result.actual_reps, + result.actual_score, + result.actual_score_time_ms, + result.completed_at + FROM workout_history_set_results AS result + INNER JOIN workout_history AS history + ON history.id = result.workout_history_id + WHERE result.deleted_at IS NULL + AND history.deleted_at IS NULL + AND history.completed = 1 + AND result.status = ? + AND result.source_exercise_id_snapshot = ? + AND ($valuePredicate) +), +latest_history AS ( + SELECT workout_history_id + FROM candidates + ORDER BY history_started_at DESC + LIMIT 1 +) +SELECT * +FROM candidates +WHERE workout_history_id = (SELECT workout_history_id FROM latest_history) +ORDER BY + CASE WHEN set_index = ? THEN 0 ELSE 1 END, + set_index DESC +LIMIT 1 +''', + variables: [ + const Variable('completed'), + Variable(exerciseId), + Variable(currentSetIndex), + ], + ) + .getSingleOrNull(); + return row == null ? null : _setPerformanceFromRow(row); + } + + @override + Future findBestMetricPerformance({ + required String exerciseId, + required PerformanceMetric metric, + required domain.ScoreInputMode scoreInputMode, + }) async { + final metricSql = _metricSql(metric, scoreInputMode); + final row = await database + .customSelect( + ''' +SELECT + result.workout_history_id, + history.started_at AS history_started_at, + result.set_index, + result.exercise_name_snapshot, + result.score_input_mode_snapshot, + result.actual_time_ms, + result.actual_reps, + result.actual_score, + result.actual_score_time_ms, + result.completed_at +FROM workout_history_set_results AS result +INNER JOIN workout_history AS history + ON history.id = result.workout_history_id +WHERE result.deleted_at IS NULL + AND history.deleted_at IS NULL + AND history.completed = 1 + AND result.status = ? + AND result.source_exercise_id_snapshot = ? + AND ${metricSql.predicate} +ORDER BY ${metricSql.ordering}, + history.started_at DESC, + result.completed_at DESC +LIMIT 1 +''', + variables: [ + const Variable('completed'), + Variable(exerciseId), + ], + ) + .getSingleOrNull(); + return row == null ? null : _metricPerformanceFromRow(row, metric: metric); + } +} + Future _upsertWithChangeLog({ required db.AppDatabase database, required String tableName, @@ -2863,6 +2995,94 @@ domain.ActiveRestState _activeRestStateFromCustomRow(QueryRow row) { ); } +WorkoutHistorySetPerformance _setPerformanceFromRow(QueryRow row) { + final data = row.data; + return WorkoutHistorySetPerformance( + workoutHistoryId: data['workout_history_id'] as String, + startedAt: _dateTimeFromData(data, 'history_started_at'), + setIndex: data['set_index'] as int, + exerciseNameSnapshot: data['exercise_name_snapshot'] as String, + scoreInputModeSnapshot: _scoreInputModeFromDb( + data['score_input_mode_snapshot'] as String, + ), + actualTimeMs: data['actual_time_ms'] as int?, + actualReps: data['actual_reps'] as int?, + actualScore: (data['actual_score'] as num?)?.toDouble(), + actualScoreTimeMs: data['actual_score_time_ms'] as int?, + completedAt: _dateTimeOrNullFromData(data, 'completed_at'), + ); +} + +WorkoutHistoryMetricPerformance _metricPerformanceFromRow( + QueryRow row, { + required PerformanceMetric metric, +}) { + final set = _setPerformanceFromRow(row); + return WorkoutHistoryMetricPerformance( + workoutHistoryId: set.workoutHistoryId, + startedAt: set.startedAt, + setIndex: set.setIndex, + exerciseNameSnapshot: set.exerciseNameSnapshot, + metric: metric, + scoreInputModeSnapshot: set.scoreInputModeSnapshot, + actualTimeMs: set.actualTimeMs, + actualReps: set.actualReps, + actualScore: set.actualScore, + actualScoreTimeMs: set.actualScoreTimeMs, + completedAt: set.completedAt, + ); +} + +String? _activeMeasuresPredicate(ActivePerformanceMeasures measures) { + final predicates = []; + if (measures.scoreEnabled) { + predicates.add( + measures.scoreInputMode == domain.ScoreInputMode.stopwatch + ? "(result.score_input_mode_snapshot = 'stopwatch' " + 'AND result.actual_score_time_ms IS NOT NULL)' + : "(result.score_input_mode_snapshot = 'manual' " + 'AND result.actual_score IS NOT NULL)', + ); + } + if (measures.repsEnabled) { + predicates.add('result.actual_reps IS NOT NULL'); + } + if (measures.timeEnabled) { + predicates.add('result.actual_time_ms IS NOT NULL'); + } + return predicates.isEmpty ? null : predicates.join(' OR '); +} + +({String predicate, String ordering}) _metricSql( + PerformanceMetric metric, + domain.ScoreInputMode scoreInputMode, +) { + return switch (metric) { + PerformanceMetric.score => + scoreInputMode == domain.ScoreInputMode.stopwatch + ? ( + predicate: + "result.score_input_mode_snapshot = 'stopwatch' " + 'AND result.actual_score_time_ms IS NOT NULL', + ordering: 'result.actual_score_time_ms ASC', + ) + : ( + predicate: + "result.score_input_mode_snapshot = 'manual' " + 'AND result.actual_score IS NOT NULL', + ordering: 'result.actual_score DESC', + ), + PerformanceMetric.reps => ( + predicate: 'result.actual_reps IS NOT NULL', + ordering: 'result.actual_reps DESC', + ), + PerformanceMetric.time => ( + predicate: 'result.actual_time_ms IS NOT NULL', + ordering: 'result.actual_time_ms DESC', + ), + }; +} + db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion( domain.ActiveScoreStopwatchState state, ) { @@ -3108,6 +3328,7 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion( actualScoreTimeMs: Value(result.actualScoreTimeMs), scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), + sourceExerciseIdSnapshot: Value(result.sourceExerciseIdSnapshot), startedAt: Value(_utcOrNull(result.startedAt)), completedAt: Value(_utcOrNull(result.completedAt)), status: Value(_setResultStatusToDb(result.status)), @@ -3160,6 +3381,7 @@ db.WorkoutHistoryStepResultsCompanion _workoutHistoryStepResultCompanion( actualScore: Value(result.actualScore), actualScoreTimeMs: Value(result.actualScoreTimeMs), note: Value(result.note), + sourceExerciseIdSnapshot: Value(result.sourceExerciseIdSnapshot), ); } @@ -3210,6 +3432,7 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow( actualScoreTimeMs: row.actualScoreTimeMs, scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, + sourceExerciseIdSnapshot: row.sourceExerciseIdSnapshot, startedAt: _utcOrNull(row.startedAt), completedAt: _utcOrNull(row.completedAt), status: _setResultStatusFromDb(row.status), @@ -3249,6 +3472,7 @@ domain.WorkoutHistoryStepResult _workoutHistoryStepResultFromRow( actualScore: row.actualScore, actualScoreTimeMs: row.actualScoreTimeMs, note: row.note, + sourceExerciseIdSnapshot: row.sourceExerciseIdSnapshot, ); } diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 3fbad7b..7297fc1 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -745,6 +745,7 @@ class WorkoutHistorySetResults extends SyncableTable { IntColumn get actualScoreTimeMs => integer().nullable()(); TextColumn get scoreLabelSnapshot => text().nullable()(); TextColumn get scoreUnitSnapshot => text().nullable()(); + TextColumn get sourceExerciseIdSnapshot => text().nullable()(); DateTimeColumn get startedAt => dateTime().nullable()(); DateTimeColumn get completedAt => dateTime().nullable()(); TextColumn get status => text().withDefault(const Constant('completed'))(); @@ -821,6 +822,7 @@ class WorkoutHistoryStepResults extends SyncableTable { RealColumn get actualScore => real().nullable()(); IntColumn get actualScoreTimeMs => integer().nullable()(); TextColumn get note => text().nullable()(); + TextColumn get sourceExerciseIdSnapshot => text().nullable()(); @override List get customConstraints => [ diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 76eef36..b3a8e62 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -14,6 +14,8 @@ void main() { late local.DriftActiveSessionRepository activeRepository; late local.DriftWorkoutTemplateRepository templateRepository; late local.DriftWorkoutHistoryRepository historyRepository; + late local.DriftExercisePerformanceReferenceRepository + performanceReferenceRepository; setUp(() { database = local.AppDatabase(NativeDatabase.memory()); @@ -22,6 +24,8 @@ void main() { activeRepository = local.DriftActiveSessionRepository(database); templateRepository = local.DriftWorkoutTemplateRepository(database); historyRepository = local.DriftWorkoutHistoryRepository(database); + performanceReferenceRepository = + local.DriftExercisePerformanceReferenceRepository(database); }); tearDown(() async { @@ -843,6 +847,265 @@ void main() { expect(restored.results.single.actualReps, 10); expect(restored.results.single.actualScore, 80); expect(restored.results.single.scoreUnitSnapshot, 'kg'); + expect(restored.results.single.sourceExerciseIdSnapshot, 'exercise-1'); + }, + ); + + test( + 'performance reference ignores skipped and null values for latest set', + () async { + final now = DateTime.utc(2026, 7, 22, 10); + await historyRepository.save( + _history( + id: 'history-old', + startedAt: now.subtract(const Duration(days: 2)), + result: _historySetResult( + id: 'result-old', + historyId: 'history-old', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now.subtract(const Duration(days: 2)), + actualReps: 8, + ), + ), + ); + await historyRepository.save( + _history( + id: 'history-new', + startedAt: now, + results: [ + _historySetResult( + id: 'result-skipped', + historyId: 'history-new', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now, + status: SetResultStatus.skipped, + ), + _historySetResult( + id: 'result-null', + historyId: 'history-new', + sourceExerciseId: 'exercise-1', + setIndex: 1, + startedAt: now, + ), + _historySetResult( + id: 'result-value', + historyId: 'history-new', + sourceExerciseId: 'exercise-1', + setIndex: 2, + startedAt: now, + actualReps: 11, + ), + ], + ), + ); + + final latest = await performanceReferenceRepository + .findLatestSetPerformance( + exerciseId: 'exercise-1', + activeMeasures: const ActivePerformanceMeasures( + timeEnabled: false, + repsEnabled: true, + scoreEnabled: false, + ), + currentSetIndex: 0, + ); + + expect(latest, isNotNull); + expect(latest!.workoutHistoryId, 'history-new'); + expect(latest.setIndex, 2); + expect(latest.actualReps, 11); + }, + ); + + test('performance reference uses same set when available', () async { + final now = DateTime.utc(2026, 7, 22, 11); + await historyRepository.save( + _history( + id: 'history-sets', + startedAt: now, + results: [ + _historySetResult( + id: 'result-set-0', + historyId: 'history-sets', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now, + actualReps: 7, + ), + _historySetResult( + id: 'result-set-1', + historyId: 'history-sets', + sourceExerciseId: 'exercise-1', + setIndex: 1, + startedAt: now, + actualReps: 9, + ), + ], + ), + ); + + final latest = await performanceReferenceRepository + .findLatestSetPerformance( + exerciseId: 'exercise-1', + activeMeasures: const ActivePerformanceMeasures( + timeEnabled: false, + repsEnabled: true, + scoreEnabled: false, + ), + currentSetIndex: 0, + ); + + expect(latest, isNotNull); + expect(latest!.setIndex, 0); + expect(latest.actualReps, 7); + }); + + test('performance reference finds records by metric rules', () async { + final now = DateTime.utc(2026, 7, 22, 12); + await historyRepository.save( + _history( + id: 'history-records', + startedAt: now, + results: [ + _historySetResult( + id: 'result-reps-low', + historyId: 'history-records', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now, + actualTimeMs: 30000, + actualReps: 6, + actualScore: 15, + ), + _historySetResult( + id: 'result-reps-high', + historyId: 'history-records', + sourceExerciseId: 'exercise-1', + setIndex: 1, + startedAt: now, + actualTimeMs: 45000, + actualReps: 12, + actualScore: 20, + ), + _historySetResult( + id: 'result-stopwatch-slow', + historyId: 'history-records', + sourceExerciseId: 'exercise-1', + setIndex: 2, + startedAt: now, + scoreInputMode: ScoreInputMode.stopwatch, + actualScoreTimeMs: 11000, + ), + _historySetResult( + id: 'result-stopwatch-fast', + historyId: 'history-records', + sourceExerciseId: 'exercise-1', + setIndex: 3, + startedAt: now, + scoreInputMode: ScoreInputMode.stopwatch, + actualScoreTimeMs: 9000, + ), + ], + ), + ); + + final reps = await performanceReferenceRepository.findBestMetricPerformance( + exerciseId: 'exercise-1', + metric: PerformanceMetric.reps, + scoreInputMode: ScoreInputMode.manual, + ); + final time = await performanceReferenceRepository.findBestMetricPerformance( + exerciseId: 'exercise-1', + metric: PerformanceMetric.time, + scoreInputMode: ScoreInputMode.manual, + ); + final manualScore = await performanceReferenceRepository + .findBestMetricPerformance( + exerciseId: 'exercise-1', + metric: PerformanceMetric.score, + scoreInputMode: ScoreInputMode.manual, + ); + final stopwatchScore = await performanceReferenceRepository + .findBestMetricPerformance( + exerciseId: 'exercise-1', + metric: PerformanceMetric.score, + scoreInputMode: ScoreInputMode.stopwatch, + ); + + expect(reps!.actualReps, 12); + expect(time!.actualTimeMs, 45000); + expect(manualScore!.actualScore, 20); + expect(stopwatchScore!.actualScoreTimeMs, 9000); + }); + + test('performance reference matches archived source exercise id', () async { + final now = DateTime.utc(2026, 7, 22, 13); + await exerciseRepository.save( + Exercise( + metadata: _metadata('exercise-archived', now), + name: 'Archived drill', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + archivedAt: now, + ), + ); + await historyRepository.save( + _history( + id: 'history-archived', + startedAt: now, + result: _historySetResult( + id: 'result-archived', + historyId: 'history-archived', + sourceExerciseId: 'exercise-archived', + setIndex: 0, + startedAt: now, + actualReps: 13, + ), + ), + ); + + expect( + await performanceReferenceRepository.hasAnyCompletedHistoryForExercise( + 'exercise-archived', + ), + isTrue, + ); + final latest = await performanceReferenceRepository + .findLatestSetPerformance( + exerciseId: 'exercise-archived', + activeMeasures: const ActivePerformanceMeasures( + timeEnabled: false, + repsEnabled: true, + scoreEnabled: false, + ), + currentSetIndex: 0, + ); + expect(latest!.actualReps, 13); + }); + + test( + 'performance reference use case prioritizes record score metric', + () async { + final repository = _FakePerformanceReferenceRepository(); + final useCase = ExercisePerformanceReferenceUseCase( + repository: repository, + ); + + final reference = await useCase.getExercisePerformanceReference( + exerciseId: 'exercise-1', + activeMeasures: const ActivePerformanceMeasures( + timeEnabled: true, + repsEnabled: true, + scoreEnabled: true, + ), + currentSetIndex: 0, + ); + + expect(reference.hasAnyHistoryForExercise, isTrue); + expect(repository.requestedMetric, PerformanceMetric.score); }, ); } @@ -858,6 +1121,7 @@ String _resolvedSnapshot() { 'exercises': [ { 'id': 'exercise-snapshot-1', + 'sourceExerciseId': 'exercise-1', 'exerciseNameSnapshot': 'Squat', 'setsCount': 1, 'timeEnabled': true, @@ -888,6 +1152,71 @@ EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) { ); } +WorkoutHistory _history({ + required String id, + required DateTime startedAt, + WorkoutHistorySetResult? result, + List? results, +}) { + return WorkoutHistory( + metadata: _metadata(id, startedAt), + nameSnapshot: id, + startedAt: startedAt, + endedAt: startedAt.add(const Duration(minutes: 5)), + totalActiveMs: 300000, + completed: true, + historySnapshotJson: '{"name":"$id"}', + results: results ?? [result!], + ); +} + +WorkoutHistorySetResult _historySetResult({ + required String id, + required String historyId, + required String sourceExerciseId, + required int setIndex, + required DateTime startedAt, + int? actualTimeMs, + int? actualReps, + double? actualScore, + int? actualScoreTimeMs, + ScoreInputMode scoreInputMode = ScoreInputMode.manual, + SetResultStatus status = SetResultStatus.completed, +}) { + final scoreEnabled = actualScore != null || actualScoreTimeMs != null; + return WorkoutHistorySetResult( + metadata: _metadata(id, startedAt), + workoutHistoryId: historyId, + programSnapshotId: 'program-snapshot', + exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId', + programIndex: 0, + exerciseIndex: 0, + setIndex: setIndex, + programNameSnapshot: 'Program', + exerciseNameSnapshot: 'Exercise', + timeEnabledSnapshot: actualTimeMs != null, + repsEnabledSnapshot: + actualReps != null || (actualTimeMs == null && !scoreEnabled), + scoreEnabledSnapshot: scoreEnabled, + actualTimeMs: actualTimeMs, + actualReps: actualReps, + actualScore: actualScore, + actualScoreTimeMs: actualScoreTimeMs, + scoreInputModeSnapshot: scoreInputMode, + scoreLabelSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual + ? 'Score' + : null, + scoreUnitSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual + ? 'pts' + : null, + sourceExerciseIdSnapshot: sourceExerciseId, + completedAt: status == SetResultStatus.completed + ? startedAt.add(const Duration(minutes: 1)) + : null, + status: status, + ); +} + ProgramExercise _programExercise( String id, DateTime now, { @@ -1019,3 +1348,40 @@ final class _FakeIds implements IdGenerator { return 'id-$_next'; } } + +final class _FakePerformanceReferenceRepository + implements ExercisePerformanceReferenceRepository { + PerformanceMetric? requestedMetric; + + @override + Future hasAnyCompletedHistoryForExercise(String exerciseId) async { + return true; + } + + @override + Future findLatestSetPerformance({ + required String exerciseId, + required ActivePerformanceMeasures activeMeasures, + required int currentSetIndex, + }) async { + return null; + } + + @override + Future findBestMetricPerformance({ + required String exerciseId, + required PerformanceMetric metric, + required ScoreInputMode scoreInputMode, + }) async { + requestedMetric = metric; + return WorkoutHistoryMetricPerformance( + workoutHistoryId: 'history-1', + startedAt: DateTime.utc(2026, 7, 22), + setIndex: 0, + exerciseNameSnapshot: 'Exercise', + metric: metric, + scoreInputModeSnapshot: scoreInputMode, + actualScore: 10, + ); + } +} diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 4a3e500..172ee3f 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -211,6 +211,9 @@ final class _FakeBootstrap implements AppDependencies { workoutHistoryUseCases = WorkoutHistoryUseCases( repository: _FakeWorkoutHistoryRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ), + exercisePerformanceReferenceUseCase = ExercisePerformanceReferenceUseCase( + repository: _FakeExercisePerformanceReferenceRepository(), ); @override @@ -245,6 +248,9 @@ final class _FakeBootstrap implements AppDependencies { @override final WorkoutHistoryUseCases workoutHistoryUseCases; + + @override + final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; } String _sessionSnapshot() { @@ -704,3 +710,29 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future saveStepResult(WorkoutHistoryStepResult result) async {} } + +final class _FakeExercisePerformanceReferenceRepository + implements ExercisePerformanceReferenceRepository { + @override + Future hasAnyCompletedHistoryForExercise(String exerciseId) async { + return false; + } + + @override + Future findLatestSetPerformance({ + required String exerciseId, + required ActivePerformanceMeasures activeMeasures, + required int currentSetIndex, + }) async { + return null; + } + + @override + Future findBestMetricPerformance({ + required String exerciseId, + required PerformanceMetric metric, + required ScoreInputMode scoreInputMode, + }) async { + return null; + } +}