diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 1c4ea26..e7369fb 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; + ProgressionStatsUseCase get progressionStatsUseCase; ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase; SyncUseCases get syncUseCases; ShareUseCases get shareUseCases; @@ -30,6 +31,7 @@ final class AppBootstrap implements AppDependencies { required this.activeExerciseStepUseCases, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, + required this.progressionStatsUseCase, required this.exercisePerformanceReferenceUseCase, required this.syncUseCases, required this.shareUseCases, @@ -56,6 +58,8 @@ final class AppBootstrap implements AppDependencies { @override final WorkoutHistoryUseCases workoutHistoryUseCases; @override + final ProgressionStatsUseCase progressionStatsUseCase; + @override final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; @override final SyncUseCases syncUseCases; @@ -81,6 +85,9 @@ final class AppBootstrap implements AppDependencies { final templateRepository = DriftWorkoutTemplateRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database); final historyRepository = DriftWorkoutHistoryRepository(database); + final progressionStatsRepository = DriftProgressionStatsRepository( + database, + ); final performanceReferenceRepository = DriftExercisePerformanceReferenceRepository(database); final ids = LocalIdGenerator(); @@ -165,6 +172,10 @@ final class AppBootstrap implements AppDependencies { repository: historyRepository, clock: clock, ), + progressionStatsUseCase: ProgressionStatsUseCase( + repository: progressionStatsRepository, + clock: clock, + ), exercisePerformanceReferenceUseCase: ExercisePerformanceReferenceUseCase( repository: performanceReferenceRepository, ), diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 9a878a6..f01ee29 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -27,6 +27,232 @@ final class StarterContent { final WorkoutTemplate workoutTemplate; } +enum ProgressionPeriod { fourWeeks, threeMonths, all } + +enum ProgressionMeasure { manualScore, stopwatchScore, reps, time } + +enum ProgressionRangeLabelKind { sinceDate, sinceFirstSession, none } + +enum ProgressionSeriesState { + ready, + singlePoint, + emptyForPeriod, + emptyButHasAllTimeData, + noGraphableMeasure, + textScoreOnly, +} + +enum ProgressionSeriesSummaryKind { bestLast, totals, none } + +final class ProgressionDateRange { + const ProgressionDateRange({required this.startedAt, required this.endedAt}); + + final DateTime? startedAt; + final DateTime endedAt; +} + +final class ProgressionGlobalStatsData { + const ProgressionGlobalStatsData({ + required this.completedSessionCount, + required this.totalActiveMs, + required this.activeWeekStarts, + required this.hasAnyCompletedHistory, + this.firstCompletedSessionAt, + }); + + final int completedSessionCount; + final int totalActiveMs; + final List activeWeekStarts; + final bool hasAnyCompletedHistory; + final DateTime? firstCompletedSessionAt; +} + +final class ProgressionExerciseOptionData { + const ProgressionExerciseOptionData({ + required this.exerciseKey, + required this.nameSnapshot, + required this.isArchived, + required this.lastPerformedAt, + }); + + final String exerciseKey; + final String nameSnapshot; + final bool isArchived; + final DateTime lastPerformedAt; +} + +final class ProgressionMeasureOptionData { + const ProgressionMeasureOptionData({ + required this.measure, + required this.label, + this.scoreLabel, + this.scoreUnit, + required this.lowerIsBetter, + }); + + final ProgressionMeasure measure; + final String label; + final String? scoreLabel; + final String? scoreUnit; + final bool lowerIsBetter; +} + +final class ProgressionExerciseSeriesData { + const ProgressionExerciseSeriesData({ + required this.exerciseKey, + required this.exerciseNameSnapshot, + required this.measure, + required this.points, + required this.hasAnyAllTimeData, + required this.hasAnyCompletedExerciseResult, + }); + + final String exerciseKey; + final String exerciseNameSnapshot; + final ProgressionMeasureOptionData measure; + final List points; + final bool hasAnyAllTimeData; + final bool hasAnyCompletedExerciseResult; +} + +final class ProgressionPointData { + const ProgressionPointData({ + required this.workoutHistoryId, + required this.workoutNameSnapshot, + required this.startedAt, + required this.value, + required this.rawValue, + }); + + final String workoutHistoryId; + final String workoutNameSnapshot; + final DateTime startedAt; + final double value; + final Object rawValue; +} + +abstract interface class ProgressionStatsRepository { + Future readGlobalStats( + ProgressionDateRange range, + ); + Future> listExerciseOptions( + ProgressionDateRange range, + ); + Future> listMeasureOptions({ + required ProgressionDateRange range, + required String exerciseKey, + }); + Future readExerciseSeries({ + required ProgressionDateRange range, + required String exerciseKey, + required ProgressionMeasure measure, + }); +} + +final class ProgressionOverview { + const ProgressionOverview({ + required this.period, + required this.rangeLabelKind, + required this.completedSessionCount, + required this.totalActiveMs, + required this.activeWeekCount, + required this.totalWeekCount, + required this.hasAnyCompletedHistory, + required this.exerciseOptions, + }); + + final ProgressionPeriod period; + final ProgressionRangeLabelKind rangeLabelKind; + final int completedSessionCount; + final int totalActiveMs; + final int activeWeekCount; + final int? totalWeekCount; + final bool hasAnyCompletedHistory; + final List exerciseOptions; +} + +final class ProgressionExerciseOption { + const ProgressionExerciseOption({ + required this.exerciseKey, + required this.nameSnapshot, + required this.isArchived, + required this.lastPerformedAt, + required this.measures, + }); + + final String exerciseKey; + final String nameSnapshot; + final bool isArchived; + final DateTime lastPerformedAt; + final List measures; +} + +final class ProgressionMeasureOption { + const ProgressionMeasureOption({ + required this.measure, + required this.label, + this.scoreLabel, + this.scoreUnit, + required this.lowerIsBetter, + }); + + final ProgressionMeasure measure; + final String label; + final String? scoreLabel; + final String? scoreUnit; + final bool lowerIsBetter; +} + +final class ProgressionExerciseSeries { + const ProgressionExerciseSeries({ + required this.exerciseKey, + required this.exerciseNameSnapshot, + required this.measure, + required this.points, + required this.summary, + required this.state, + }); + + final String exerciseKey; + final String exerciseNameSnapshot; + final ProgressionMeasureOption measure; + final List points; + final ProgressionSeriesSummary summary; + final ProgressionSeriesState state; +} + +final class ProgressionPoint { + const ProgressionPoint({ + required this.workoutHistoryId, + required this.workoutNameSnapshot, + required this.startedAt, + required this.value, + required this.rawValue, + }); + + final String workoutHistoryId; + final String workoutNameSnapshot; + final DateTime startedAt; + final double value; + final Object rawValue; +} + +final class ProgressionSeriesSummary { + const ProgressionSeriesSummary({ + required this.kind, + this.best, + this.last, + this.recentTotal, + this.lastSessionTotal, + }); + + final ProgressionSeriesSummaryKind kind; + final Object? best; + final Object? last; + final Object? recentTotal; + final Object? lastSessionTotal; +} + abstract interface class StarterSeedStateRepository { Future readAppliedStarterSeedVersion(); Future writeAppliedStarterSeedVersion(int version, DateTime appliedAt); diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index d1be130..d7427cc 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3440,6 +3440,220 @@ final class WorkoutHistoryUseCases { Future delete(String id) => repository.delete(id, clock.now()); } +final class ProgressionStatsUseCase { + const ProgressionStatsUseCase({ + required this.repository, + required this.clock, + }); + + final ProgressionStatsRepository repository; + final Clock clock; + + Future getOverview(ProgressionPeriod period) async { + final range = _progressionDateRange(period, clock.now()); + final global = await repository.readGlobalStats(range); + final exerciseData = await repository.listExerciseOptions(range); + final exerciseOptions = []; + for (final option in exerciseData) { + final measures = await repository.listMeasureOptions( + range: range, + exerciseKey: option.exerciseKey, + ); + exerciseOptions.add( + ProgressionExerciseOption( + exerciseKey: option.exerciseKey, + nameSnapshot: option.nameSnapshot, + isArchived: option.isArchived, + lastPerformedAt: option.lastPerformedAt, + measures: measures.map(_progressionMeasureOption).toList(), + ), + ); + } + return ProgressionOverview( + period: period, + rangeLabelKind: _rangeLabelKind(period, global), + completedSessionCount: global.completedSessionCount, + totalActiveMs: global.totalActiveMs, + activeWeekCount: global.activeWeekStarts.length, + totalWeekCount: _totalWeekCount(period, range), + hasAnyCompletedHistory: global.hasAnyCompletedHistory, + exerciseOptions: exerciseOptions, + ); + } + + Future getExerciseSeries({ + required ProgressionPeriod period, + required String exerciseKey, + required ProgressionMeasure measure, + }) async { + final range = _progressionDateRange(period, clock.now()); + final data = await repository.readExerciseSeries( + range: range, + exerciseKey: exerciseKey, + measure: measure, + ); + final points = data.points + .map( + (point) => ProgressionPoint( + workoutHistoryId: point.workoutHistoryId, + workoutNameSnapshot: point.workoutNameSnapshot, + startedAt: point.startedAt, + value: point.value, + rawValue: point.rawValue, + ), + ) + .toList(); + return ProgressionExerciseSeries( + exerciseKey: data.exerciseKey, + exerciseNameSnapshot: data.exerciseNameSnapshot, + measure: _progressionMeasureOption(data.measure), + points: points, + summary: _progressionSeriesSummary(measure, points), + state: _progressionSeriesState( + period: period, + points: points, + hasAnyAllTimeData: data.hasAnyAllTimeData, + hasAnyCompletedExerciseResult: data.hasAnyCompletedExerciseResult, + ), + ); + } +} + +ProgressionDateRange _progressionDateRange( + ProgressionPeriod period, + DateTime now, +) { + final endedAt = now.toUtc(); + switch (period) { + case ProgressionPeriod.fourWeeks: + return ProgressionDateRange( + startedAt: _startOfLocalWeek( + endedAt.toLocal(), + ).subtract(const Duration(days: 21)), + endedAt: endedAt, + ); + case ProgressionPeriod.threeMonths: + return ProgressionDateRange( + startedAt: DateTime( + endedAt.toLocal().year, + endedAt.toLocal().month - 3, + endedAt.toLocal().day, + ), + endedAt: endedAt, + ); + case ProgressionPeriod.all: + return ProgressionDateRange(startedAt: null, endedAt: endedAt); + } +} + +ProgressionRangeLabelKind _rangeLabelKind( + ProgressionPeriod period, + ProgressionGlobalStatsData data, +) { + if (!data.hasAnyCompletedHistory) { + return ProgressionRangeLabelKind.none; + } + return period == ProgressionPeriod.all + ? ProgressionRangeLabelKind.sinceFirstSession + : ProgressionRangeLabelKind.sinceDate; +} + +int? _totalWeekCount(ProgressionPeriod period, ProgressionDateRange range) { + switch (period) { + case ProgressionPeriod.fourWeeks: + return 4; + case ProgressionPeriod.threeMonths: + final startedAt = range.startedAt; + if (startedAt == null) { + return null; + } + final firstWeek = _startOfLocalWeek(startedAt.toLocal()); + final lastWeek = _startOfLocalWeek(range.endedAt.toLocal()); + return lastWeek.difference(firstWeek).inDays ~/ 7 + 1; + case ProgressionPeriod.all: + return null; + } +} + +DateTime _startOfLocalWeek(DateTime value) { + final localDay = DateTime(value.year, value.month, value.day); + return localDay.subtract(Duration(days: localDay.weekday - DateTime.monday)); +} + +ProgressionMeasureOption _progressionMeasureOption( + ProgressionMeasureOptionData data, +) { + return ProgressionMeasureOption( + measure: data.measure, + label: data.label, + scoreLabel: data.scoreLabel, + scoreUnit: data.scoreUnit, + lowerIsBetter: data.lowerIsBetter, + ); +} + +ProgressionSeriesState _progressionSeriesState({ + required ProgressionPeriod period, + required List points, + required bool hasAnyAllTimeData, + required bool hasAnyCompletedExerciseResult, +}) { + if (points.length > 1) { + return ProgressionSeriesState.ready; + } + if (points.length == 1) { + return ProgressionSeriesState.singlePoint; + } + if (period != ProgressionPeriod.all && hasAnyAllTimeData) { + return ProgressionSeriesState.emptyButHasAllTimeData; + } + if (hasAnyCompletedExerciseResult) { + return ProgressionSeriesState.noGraphableMeasure; + } + return ProgressionSeriesState.emptyForPeriod; +} + +ProgressionSeriesSummary _progressionSeriesSummary( + ProgressionMeasure measure, + List points, +) { + if (points.isEmpty) { + return const ProgressionSeriesSummary( + kind: ProgressionSeriesSummaryKind.none, + ); + } + switch (measure) { + case ProgressionMeasure.manualScore: + final best = points + .map((point) => point.rawValue as double) + .reduce((current, next) => current > next ? current : next); + return ProgressionSeriesSummary( + kind: ProgressionSeriesSummaryKind.bestLast, + best: best, + last: points.last.rawValue, + ); + case ProgressionMeasure.stopwatchScore: + final best = points + .map((point) => point.rawValue as int) + .reduce((current, next) => current < next ? current : next); + return ProgressionSeriesSummary( + kind: ProgressionSeriesSummaryKind.bestLast, + best: best, + last: points.last.rawValue, + ); + case ProgressionMeasure.reps: + case ProgressionMeasure.time: + final recentTotal = points + .map((point) => point.rawValue as int) + .fold(0, (total, value) => total + value); + return ProgressionSeriesSummary( + kind: ProgressionSeriesSummaryKind.totals, + recentTotal: recentTotal, + lastSessionTotal: points.last.rawValue, + ); + } +} + enum SetPositionStatus { pending, completed, skipped } final class SetResultPositionState { diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 49483f5..f173f73 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 => 17; + int get schemaVersion => 18; @override MigrationStrategy get migration { @@ -228,10 +228,21 @@ final class AppDatabase extends _$AppDatabase { 'CREATE INDEX IF NOT EXISTS idx_workout_history_started_at ' 'ON workout_history (started_at)', ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_workout_history_completed_started ' + 'ON workout_history (completed, started_at) ' + 'WHERE deleted_at IS NULL', + ); await customStatement( '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_history_set_progression_exercise ' + 'ON workout_history_set_results (source_exercise_id_snapshot, ' + 'exercise_snapshot_id, workout_history_id, status) ' + 'WHERE deleted_at IS NULL', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS ' 'idx_workout_history_set_results_source_exercise ' @@ -243,6 +254,12 @@ final class AppDatabase extends _$AppDatabase { '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_history_step_progression_exercise ' + 'ON workout_history_step_results (source_exercise_id_snapshot, ' + 'exercise_snapshot_id, workout_history_id, status) ' + 'WHERE deleted_at IS NULL', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS ' 'idx_workout_history_step_results_source_exercise ' diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 21ae6d3..88ae116 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -1734,6 +1734,510 @@ LIMIT 1 } } +final class DriftProgressionStatsRepository + implements ProgressionStatsRepository { + const DriftProgressionStatsRepository(this.database); + + final db.AppDatabase database; + + @override + Future readGlobalStats( + ProgressionDateRange range, + ) async { + final variables = _rangeVariables(range); + final row = await database.customSelect(''' +SELECT + COUNT(*) AS completed_count, + COALESCE(SUM(total_active_ms), 0) AS total_active_ms, + MIN(started_at) AS first_started_at +FROM workout_history +WHERE deleted_at IS NULL + AND completed = 1 + AND started_at <= ? + ${range.startedAt == null ? '' : 'AND started_at >= ?'} +''', variables: variables).getSingle(); + final anyRow = await database.customSelect(''' +SELECT MIN(started_at) AS first_started_at +FROM workout_history +WHERE deleted_at IS NULL + AND completed = 1 +''').getSingle(); + final weekRows = await database.customSelect(''' +SELECT started_at +FROM workout_history +WHERE deleted_at IS NULL + AND completed = 1 + AND started_at <= ? + ${range.startedAt == null ? '' : 'AND started_at >= ?'} +''', variables: variables).get(); + final weekStarts = { + for (final row in weekRows) + _startOfLocalWeekFromUtc(_dateTimeFromData(row.data, 'started_at')), + }.toList()..sort(); + return ProgressionGlobalStatsData( + completedSessionCount: row.data['completed_count'] as int, + totalActiveMs: row.data['total_active_ms'] as int, + activeWeekStarts: weekStarts, + hasAnyCompletedHistory: anyRow.data['first_started_at'] != null, + firstCompletedSessionAt: _dateTimeOrNullFromData( + anyRow.data, + 'first_started_at', + ), + ); + } + + @override + Future> listExerciseOptions( + ProgressionDateRange range, + ) async { + final rows = await database.customSelect(''' +WITH result_projection AS ( + ${_progressionResultProjection(range)} +), +ranked AS ( + SELECT + exercise_key, + exercise_name_snapshot, + source_exercise_id_snapshot, + started_at, + ROW_NUMBER() OVER ( + PARTITION BY exercise_key + ORDER BY started_at DESC + ) AS name_rank + FROM result_projection + WHERE has_graphable_measure = 1 +) +SELECT + ranked.exercise_key, + MAX(CASE WHEN ranked.name_rank = 1 THEN ranked.exercise_name_snapshot END) + AS exercise_name_snapshot, + MAX(ranked.started_at) AS last_performed_at, + CASE + WHEN exercises.id IS NOT NULL + AND exercises.deleted_at IS NULL + AND exercises.archived_at IS NULL + THEN 0 + ELSE 1 + END AS is_archived +FROM ranked +LEFT JOIN exercises + ON exercises.id = ranked.source_exercise_id_snapshot +GROUP BY ranked.exercise_key +ORDER BY last_performed_at DESC +''', variables: _projectionVariables(range)).get(); + return rows + .map( + (row) => ProgressionExerciseOptionData( + exerciseKey: row.data['exercise_key'] as String, + nameSnapshot: row.data['exercise_name_snapshot'] as String, + isArchived: (row.data['is_archived'] as int) == 1, + lastPerformedAt: _dateTimeFromData(row.data, 'last_performed_at'), + ), + ) + .toList(); + } + + @override + Future> listMeasureOptions({ + required ProgressionDateRange range, + required String exerciseKey, + }) async { + final rows = await database + .customSelect( + ''' +WITH result_projection AS ( + ${_progressionResultProjection(range)} +), +measure_rows AS ( + SELECT 0 AS sort_order, 'manualScore' AS measure, score_label_snapshot, + score_unit_snapshot, started_at + FROM result_projection + WHERE exercise_key = ? + AND score_input_mode_snapshot = 'manual' + AND actual_score IS NOT NULL + UNION ALL + SELECT 1, 'stopwatchScore', NULL, NULL, started_at + FROM result_projection + WHERE exercise_key = ? + AND score_input_mode_snapshot = 'stopwatch' + AND actual_score_time_ms IS NOT NULL + UNION ALL + SELECT 2, 'reps', NULL, NULL, started_at + FROM result_projection + WHERE exercise_key = ? + AND actual_reps IS NOT NULL + UNION ALL + SELECT 3, 'time', NULL, NULL, started_at + FROM result_projection + WHERE exercise_key = ? + AND actual_time_ms IS NOT NULL +), +ranked AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY measure ORDER BY started_at DESC) + AS value_rank + FROM measure_rows +) +SELECT + measure, + MAX(CASE WHEN value_rank = 1 THEN score_label_snapshot END) AS score_label, + MAX(CASE WHEN value_rank = 1 THEN score_unit_snapshot END) AS score_unit, + MIN(sort_order) AS sort_order +FROM ranked +GROUP BY measure +ORDER BY sort_order +''', + variables: [ + ..._projectionVariables(range), + for (var i = 0; i < 4; i++) Variable(exerciseKey), + ], + ) + .get(); + return rows.map(_progressionMeasureOptionFromRow).toList(); + } + + @override + Future readExerciseSeries({ + required ProgressionDateRange range, + required String exerciseKey, + required ProgressionMeasure measure, + }) async { + final measureOption = + (await listMeasureOptions( + range: range, + exerciseKey: exerciseKey, + )).where((option) => option.measure == measure).firstOrNull ?? + _defaultProgressionMeasureOption(measure); + final points = await _readExercisePoints( + range: range, + exerciseKey: exerciseKey, + measure: measure, + ); + final allTimePoints = range.startedAt == null + ? points + : await _readExercisePoints( + range: ProgressionDateRange( + startedAt: null, + endedAt: range.endedAt, + ), + exerciseKey: exerciseKey, + measure: measure, + ); + final exerciseName = points.isNotEmpty + ? points.last.exerciseNameSnapshot + : allTimePoints.isNotEmpty + ? allTimePoints.last.exerciseNameSnapshot + : exerciseKey; + final hasAnyCompletedExerciseResult = await _hasAnyCompletedExerciseResult( + range: range, + exerciseKey: exerciseKey, + ); + return ProgressionExerciseSeriesData( + exerciseKey: exerciseKey, + exerciseNameSnapshot: exerciseName, + measure: measureOption, + points: [ + for (final point in points) + ProgressionPointData( + workoutHistoryId: point.workoutHistoryId, + workoutNameSnapshot: point.workoutNameSnapshot, + startedAt: point.startedAt, + value: point.value, + rawValue: point.rawValue, + ), + ], + hasAnyAllTimeData: allTimePoints.isNotEmpty, + hasAnyCompletedExerciseResult: hasAnyCompletedExerciseResult, + ); + } + + Future> _readExercisePoints({ + required ProgressionDateRange range, + required String exerciseKey, + required ProgressionMeasure measure, + }) async { + final aggregation = _progressionAggregationSql(measure); + final rows = await database + .customSelect( + ''' +WITH result_projection AS ( + ${_progressionResultProjection(range)} +) +SELECT + workout_history_id, + workout_name_snapshot, + MAX(started_at) AS started_at, + MAX(exercise_name_snapshot) AS exercise_name_snapshot, + ${aggregation.expression} AS raw_value +FROM result_projection +WHERE exercise_key = ? + AND ${aggregation.predicate} +GROUP BY workout_history_id, workout_name_snapshot +ORDER BY started_at ASC +''', + variables: [ + ..._projectionVariables(range), + Variable(exerciseKey), + ], + ) + .get(); + return rows.map((row) => _progressionPointRow(row, measure)).toList(); + } + + Future _hasAnyCompletedExerciseResult({ + required ProgressionDateRange range, + required String exerciseKey, + }) async { + final row = await database + .customSelect( + ''' +WITH result_projection AS ( + ${_progressionResultProjection(range)} +) +SELECT 1 +FROM result_projection +WHERE exercise_key = ? +LIMIT 1 +''', + variables: [ + ..._projectionVariables(range), + Variable(exerciseKey), + ], + ) + .getSingleOrNull(); + return row != null; + } +} + +List> _rangeVariables(ProgressionDateRange range) { + return [ + Variable(range.endedAt.toUtc()), + if (range.startedAt != null) Variable(range.startedAt!.toUtc()), + ]; +} + +List> _projectionVariables(ProgressionDateRange range) { + final variables = _rangeVariables(range); + return [...variables, ...variables]; +} + +String _progressionResultProjection(ProgressionDateRange range) { + final rangePredicate = + 'history.started_at <= ? ' + "${range.startedAt == null ? '' : 'AND history.started_at >= ?'}"; + final sharedWhere = + 'history.deleted_at IS NULL ' + 'AND history.completed = 1 ' + 'AND $rangePredicate'; + return ''' + SELECT + history.id AS workout_history_id, + history.name_snapshot AS workout_name_snapshot, + history.started_at, + COALESCE(result.source_exercise_id_snapshot, result.exercise_snapshot_id) + AS exercise_key, + result.source_exercise_id_snapshot, + result.exercise_snapshot_id, + result.exercise_name_snapshot, + result.score_input_mode_snapshot, + result.score_label_snapshot, + result.score_unit_snapshot, + result.actual_time_ms, + result.actual_reps, + result.actual_score, + result.actual_score_time_ms, + CASE + WHEN result.actual_time_ms IS NOT NULL + OR result.actual_reps IS NOT NULL + OR result.actual_score IS NOT NULL + OR result.actual_score_time_ms IS NOT NULL + THEN 1 ELSE 0 + END AS has_graphable_measure + 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 result.status = 'completed' + AND $sharedWhere + UNION ALL + SELECT + history.id AS workout_history_id, + history.name_snapshot AS workout_name_snapshot, + history.started_at, + COALESCE(step.source_exercise_id_snapshot, step.exercise_snapshot_id) + AS exercise_key, + step.source_exercise_id_snapshot, + step.exercise_snapshot_id, + COALESCE(parent.exercise_name_snapshot, step.step_name_snapshot) + AS exercise_name_snapshot, + step.score_input_mode_snapshot, + step.score_label_snapshot, + step.score_unit_snapshot, + step.actual_time_ms, + step.actual_reps, + step.actual_score, + step.actual_score_time_ms, + CASE + WHEN step.actual_time_ms IS NOT NULL + OR step.actual_reps IS NOT NULL + OR step.actual_score IS NOT NULL + OR step.actual_score_time_ms IS NOT NULL + THEN 1 ELSE 0 + END AS has_graphable_measure + FROM workout_history_step_results AS step + INNER JOIN workout_history AS history + ON history.id = step.workout_history_id + LEFT JOIN workout_history_set_results AS parent + ON parent.workout_history_id = step.workout_history_id + AND parent.program_index = step.program_index + AND parent.exercise_index = step.exercise_index + AND parent.set_index = step.set_index + AND parent.deleted_at IS NULL + WHERE step.deleted_at IS NULL + AND step.status = 'completed' + AND $sharedWhere +'''; +} + +ProgressionMeasureOptionData _progressionMeasureOptionFromRow(QueryRow row) { + final measure = _progressionMeasureFromDb(row.data['measure'] as String); + final defaults = _defaultProgressionMeasureOption(measure); + return ProgressionMeasureOptionData( + measure: measure, + label: defaults.label, + scoreLabel: row.data['score_label'] as String?, + scoreUnit: row.data['score_unit'] as String?, + lowerIsBetter: defaults.lowerIsBetter, + ); +} + +ProgressionMeasure _progressionMeasureFromDb(String value) { + switch (value) { + case 'manualScore': + return ProgressionMeasure.manualScore; + case 'stopwatchScore': + return ProgressionMeasure.stopwatchScore; + case 'reps': + return ProgressionMeasure.reps; + case 'time': + return ProgressionMeasure.time; + } + throw domain.DomainException('Unknown progression measure: $value'); +} + +ProgressionMeasureOptionData _defaultProgressionMeasureOption( + ProgressionMeasure measure, +) { + switch (measure) { + case ProgressionMeasure.manualScore: + return const ProgressionMeasureOptionData( + measure: ProgressionMeasure.manualScore, + label: 'Score', + lowerIsBetter: false, + ); + case ProgressionMeasure.stopwatchScore: + return const ProgressionMeasureOptionData( + measure: ProgressionMeasure.stopwatchScore, + label: 'Temps réalisé', + lowerIsBetter: true, + ); + case ProgressionMeasure.reps: + return const ProgressionMeasureOptionData( + measure: ProgressionMeasure.reps, + label: 'Répétitions', + lowerIsBetter: false, + ); + case ProgressionMeasure.time: + return const ProgressionMeasureOptionData( + measure: ProgressionMeasure.time, + label: 'Temps', + lowerIsBetter: false, + ); + } +} + +_ProgressionAggregationSql _progressionAggregationSql( + ProgressionMeasure measure, +) { + switch (measure) { + case ProgressionMeasure.manualScore: + return const _ProgressionAggregationSql( + expression: 'MAX(actual_score)', + predicate: + "score_input_mode_snapshot = 'manual' AND actual_score IS NOT NULL", + ); + case ProgressionMeasure.stopwatchScore: + return const _ProgressionAggregationSql( + expression: 'MIN(actual_score_time_ms)', + predicate: + "score_input_mode_snapshot = 'stopwatch' " + 'AND actual_score_time_ms IS NOT NULL', + ); + case ProgressionMeasure.reps: + return const _ProgressionAggregationSql( + expression: 'SUM(actual_reps)', + predicate: 'actual_reps IS NOT NULL', + ); + case ProgressionMeasure.time: + return const _ProgressionAggregationSql( + expression: 'SUM(actual_time_ms)', + predicate: 'actual_time_ms IS NOT NULL', + ); + } +} + +_ProgressionPointRow _progressionPointRow( + QueryRow row, + ProgressionMeasure measure, +) { + final raw = row.data['raw_value'] as num; + final rawValue = measure == ProgressionMeasure.manualScore + ? raw.toDouble() + : raw.toInt(); + return _ProgressionPointRow( + workoutHistoryId: row.data['workout_history_id'] as String, + workoutNameSnapshot: row.data['workout_name_snapshot'] as String, + exerciseNameSnapshot: row.data['exercise_name_snapshot'] as String, + startedAt: _dateTimeFromData(row.data, 'started_at'), + value: raw.toDouble(), + rawValue: rawValue, + ); +} + +DateTime _startOfLocalWeekFromUtc(DateTime value) { + final local = value.toLocal(); + final localDay = DateTime(local.year, local.month, local.day); + return localDay.subtract(Duration(days: localDay.weekday - DateTime.monday)); +} + +final class _ProgressionAggregationSql { + const _ProgressionAggregationSql({ + required this.expression, + required this.predicate, + }); + + final String expression; + final String predicate; +} + +final class _ProgressionPointRow { + const _ProgressionPointRow({ + required this.workoutHistoryId, + required this.workoutNameSnapshot, + required this.exerciseNameSnapshot, + required this.startedAt, + required this.value, + required this.rawValue, + }); + + final String workoutHistoryId; + final String workoutNameSnapshot; + final String exerciseNameSnapshot; + final DateTime startedAt; + final double value; + final Object rawValue; +} + Future _upsertWithChangeLog({ required db.AppDatabase database, required String tableName, diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index fde3e06..96db4b9 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -2255,6 +2255,168 @@ void main() { throwsA(isA()), ); }); + + test('Progression stats use case resolves four week overview', () async { + final repository = _FakeProgressionStatsRepository(); + final now = DateTime.utc(2026, 7, 22, 12); + repository.globalStats = ProgressionGlobalStatsData( + completedSessionCount: 2, + totalActiveMs: 180000, + activeWeekStarts: [DateTime(2026, 7, 13), DateTime(2026, 7, 20)], + hasAnyCompletedHistory: true, + ); + repository.exerciseOptions = [ + ProgressionExerciseOptionData( + exerciseKey: 'exercise-1', + nameSnapshot: 'Shoot', + isArchived: false, + lastPerformedAt: now, + ), + ]; + repository.measureOptions['exercise-1'] = const [ + ProgressionMeasureOptionData( + measure: ProgressionMeasure.manualScore, + label: 'Score', + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + lowerIsBetter: false, + ), + ]; + + final overview = await ProgressionStatsUseCase( + repository: repository, + clock: _FakeClock(now), + ).getOverview(ProgressionPeriod.fourWeeks); + + expect(repository.lastGlobalRange!.startedAt, DateTime(2026, 6, 29)); + expect(repository.lastGlobalRange!.endedAt, now); + expect(overview.totalWeekCount, 4); + expect(overview.activeWeekCount, 2); + expect(overview.rangeLabelKind, ProgressionRangeLabelKind.sinceDate); + expect( + overview.exerciseOptions.single.measures.single.scoreUnit, + 'paniers', + ); + }); + + test('Progression stats use case resolves all-time empty overview', () async { + final repository = _FakeProgressionStatsRepository(); + repository.globalStats = const ProgressionGlobalStatsData( + completedSessionCount: 0, + totalActiveMs: 0, + activeWeekStarts: [], + hasAnyCompletedHistory: false, + ); + + final overview = await ProgressionStatsUseCase( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), + ).getOverview(ProgressionPeriod.all); + + expect(repository.lastGlobalRange!.startedAt, isNull); + expect(overview.totalWeekCount, isNull); + expect(overview.rangeLabelKind, ProgressionRangeLabelKind.none); + expect(overview.hasAnyCompletedHistory, isFalse); + }); + + test( + 'Progression stats use case maps series summaries and empty period', + () async { + final repository = _FakeProgressionStatsRepository(); + final now = DateTime.utc(2026, 7, 22, 12); + repository.series = ProgressionExerciseSeriesData( + exerciseKey: 'exercise-1', + exerciseNameSnapshot: 'Shoot', + measure: const ProgressionMeasureOptionData( + measure: ProgressionMeasure.reps, + label: 'Répétitions', + lowerIsBetter: false, + ), + points: [ + ProgressionPointData( + workoutHistoryId: 'history-1', + workoutNameSnapshot: 'Séance 1', + startedAt: now.subtract(const Duration(days: 1)), + value: 12, + rawValue: 12, + ), + ProgressionPointData( + workoutHistoryId: 'history-2', + workoutNameSnapshot: 'Séance 2', + startedAt: now, + value: 15, + rawValue: 15, + ), + ], + hasAnyAllTimeData: true, + hasAnyCompletedExerciseResult: true, + ); + + final series = + await ProgressionStatsUseCase( + repository: repository, + clock: _FakeClock(now), + ).getExerciseSeries( + period: ProgressionPeriod.fourWeeks, + exerciseKey: 'exercise-1', + measure: ProgressionMeasure.reps, + ); + + expect(series.state, ProgressionSeriesState.ready); + expect(series.summary.kind, ProgressionSeriesSummaryKind.totals); + expect(series.summary.recentTotal, 27); + expect(series.summary.lastSessionTotal, 15); + + repository.series = const ProgressionExerciseSeriesData( + exerciseKey: 'exercise-1', + exerciseNameSnapshot: 'Shoot', + measure: ProgressionMeasureOptionData( + measure: ProgressionMeasure.manualScore, + label: 'Score', + lowerIsBetter: false, + ), + points: [], + hasAnyAllTimeData: true, + hasAnyCompletedExerciseResult: true, + ); + final empty = + await ProgressionStatsUseCase( + repository: repository, + clock: _FakeClock(now), + ).getExerciseSeries( + period: ProgressionPeriod.fourWeeks, + exerciseKey: 'exercise-1', + measure: ProgressionMeasure.manualScore, + ); + + expect(empty.state, ProgressionSeriesState.emptyButHasAllTimeData); + expect(empty.summary.kind, ProgressionSeriesSummaryKind.none); + + repository.series = const ProgressionExerciseSeriesData( + exerciseKey: 'exercise-1', + exerciseNameSnapshot: 'Shoot', + measure: ProgressionMeasureOptionData( + measure: ProgressionMeasure.manualScore, + label: 'Score', + lowerIsBetter: false, + ), + points: [], + hasAnyAllTimeData: false, + hasAnyCompletedExerciseResult: true, + ); + final noGraphable = + await ProgressionStatsUseCase( + repository: repository, + clock: _FakeClock(now), + ).getExerciseSeries( + period: ProgressionPeriod.all, + exerciseKey: 'exercise-1', + measure: ProgressionMeasure.manualScore, + ); + + expect(noGraphable.state, ProgressionSeriesState.noGraphableMeasure); + }, + ); } ExerciseStep _step({ @@ -2313,6 +2475,65 @@ final class _FakeIds implements IdGenerator { } } +final class _FakeProgressionStatsRepository + implements ProgressionStatsRepository { + ProgressionDateRange? lastGlobalRange; + ProgressionDateRange? lastSeriesRange; + ProgressionGlobalStatsData globalStats = const ProgressionGlobalStatsData( + completedSessionCount: 0, + totalActiveMs: 0, + activeWeekStarts: [], + hasAnyCompletedHistory: false, + ); + List exerciseOptions = const []; + final measureOptions = >{}; + ProgressionExerciseSeriesData series = const ProgressionExerciseSeriesData( + exerciseKey: 'exercise-1', + exerciseNameSnapshot: 'Exercise', + measure: ProgressionMeasureOptionData( + measure: ProgressionMeasure.manualScore, + label: 'Score', + lowerIsBetter: false, + ), + points: [], + hasAnyAllTimeData: false, + hasAnyCompletedExerciseResult: false, + ); + + @override + Future readGlobalStats( + ProgressionDateRange range, + ) async { + lastGlobalRange = range; + return globalStats; + } + + @override + Future> listExerciseOptions( + ProgressionDateRange range, + ) async { + return exerciseOptions; + } + + @override + Future> listMeasureOptions({ + required ProgressionDateRange range, + required String exerciseKey, + }) async { + return measureOptions[exerciseKey] ?? const []; + } + + @override + Future readExerciseSeries({ + required ProgressionDateRange range, + required String exerciseKey, + required ProgressionMeasure measure, + }) async { + lastSeriesRange = range; + return series; + } +} + AuthUseCases _authUseCase({ _FakeAuthTokenStore? tokenStore, _FakeOnlineAccountRepository? accountRepository, @@ -3253,10 +3474,8 @@ ActiveWorkoutSession _sessionWithSnapshot({ 'exerciseStepsSnapshot': exerciseSteps .map((step) => step.toSnapshotJson()) .toList(), - if (autoStartNextTimedStepSnapshot != null) - 'autoStartNextTimedStepSnapshot': autoStartNextTimedStepSnapshot, - if (autoStartNextTimedStepOverride != null) - 'autoStartNextTimedStepOverride': autoStartNextTimedStepOverride, + 'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot, + 'autoStartNextTimedStepOverride': ?autoStartNextTimedStepOverride, }; return ActiveWorkoutSession( metadata: _metadata('session-1'), diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index b3a8e62..f25b316 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -14,6 +14,7 @@ void main() { late local.DriftActiveSessionRepository activeRepository; late local.DriftWorkoutTemplateRepository templateRepository; late local.DriftWorkoutHistoryRepository historyRepository; + late local.DriftProgressionStatsRepository progressionStatsRepository; late local.DriftExercisePerformanceReferenceRepository performanceReferenceRepository; @@ -24,6 +25,9 @@ void main() { activeRepository = local.DriftActiveSessionRepository(database); templateRepository = local.DriftWorkoutTemplateRepository(database); historyRepository = local.DriftWorkoutHistoryRepository(database); + progressionStatsRepository = local.DriftProgressionStatsRepository( + database, + ); performanceReferenceRepository = local.DriftExercisePerformanceReferenceRepository(database); }); @@ -1086,6 +1090,283 @@ void main() { expect(latest!.actualReps, 13); }); + test( + 'progression global stats count active weeks and ignore inactive rows', + () async { + final now = DateTime.utc(2026, 7, 22, 13); + await historyRepository.save( + _history( + id: 'progression-week-1', + startedAt: now.subtract(const Duration(days: 1)), + totalActiveMs: 120000, + result: _historySetResult( + id: 'progression-result-1', + historyId: 'progression-week-1', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now.subtract(const Duration(days: 1)), + actualReps: 10, + ), + ), + ); + await historyRepository.save( + _history( + id: 'progression-week-2', + startedAt: now.subtract(const Duration(days: 8)), + totalActiveMs: 180000, + result: _historySetResult( + id: 'progression-result-2', + historyId: 'progression-week-2', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now.subtract(const Duration(days: 8)), + actualReps: 8, + ), + ), + ); + await historyRepository.save( + _history( + id: 'progression-incomplete', + startedAt: now, + completed: false, + result: _historySetResult( + id: 'progression-result-incomplete', + historyId: 'progression-incomplete', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now, + actualReps: 99, + ), + ), + ); + + final stats = await progressionStatsRepository.readGlobalStats( + ProgressionDateRange( + startedAt: now.subtract(const Duration(days: 28)), + endedAt: now, + ), + ); + + expect(stats.completedSessionCount, 2); + expect(stats.totalActiveMs, 300000); + expect(stats.activeWeekStarts, hasLength(2)); + expect(stats.hasAnyCompletedHistory, isTrue); + }, + ); + + test( + 'progression lists exercises, archive state and measure options', + () async { + final now = DateTime.utc(2026, 7, 22, 14); + await exerciseRepository.save( + Exercise( + metadata: _metadata('exercise-active', now), + name: 'Active drill', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + ), + ); + 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: 'progression-options', + startedAt: now, + results: [ + _historySetResult( + id: 'progression-active-score', + historyId: 'progression-options', + sourceExerciseId: 'exercise-active', + setIndex: 0, + startedAt: now, + actualScore: 12, + ), + _historySetResult( + id: 'progression-active-chrono', + historyId: 'progression-options', + sourceExerciseId: 'exercise-active', + setIndex: 1, + startedAt: now, + scoreInputMode: ScoreInputMode.stopwatch, + actualScoreTimeMs: 42000, + ), + _historySetResult( + id: 'progression-archived-reps', + historyId: 'progression-options', + sourceExerciseId: 'exercise-archived', + setIndex: 2, + startedAt: now, + actualReps: 15, + ), + ], + ), + ); + + final range = ProgressionDateRange( + startedAt: now.subtract(const Duration(days: 1)), + endedAt: now, + ); + final options = await progressionStatsRepository.listExerciseOptions( + range, + ); + final activeMeasures = await progressionStatsRepository + .listMeasureOptions(range: range, exerciseKey: 'exercise-active'); + + expect(options.map((option) => option.exerciseKey), [ + 'exercise-active', + 'exercise-archived', + ]); + expect(options.first.isArchived, isFalse); + expect(options.last.isArchived, isTrue); + expect(activeMeasures.map((option) => option.measure), [ + ProgressionMeasure.manualScore, + ProgressionMeasure.stopwatchScore, + ]); + expect(activeMeasures.first.scoreLabel, 'Score'); + expect(activeMeasures.first.scoreUnit, 'pts'); + expect(activeMeasures.last.lowerIsBetter, isTrue); + }, + ); + + test( + 'progression exercise series aggregate each measure by session', + () async { + final now = DateTime.utc(2026, 7, 22, 15); + await historyRepository.save( + _history( + id: 'progression-series-old', + startedAt: now.subtract(const Duration(days: 2)), + results: [ + _historySetResult( + id: 'progression-old-score-low', + historyId: 'progression-series-old', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now.subtract(const Duration(days: 2)), + actualScore: 7, + ), + _historySetResult( + id: 'progression-old-score-high', + historyId: 'progression-series-old', + sourceExerciseId: 'exercise-1', + setIndex: 1, + startedAt: now.subtract(const Duration(days: 2)), + actualScore: 9, + actualReps: 4, + actualTimeMs: 10000, + ), + ], + ), + ); + await historyRepository.save( + _history( + id: 'progression-series-new', + startedAt: now, + results: [ + _historySetResult( + id: 'progression-new-score', + historyId: 'progression-series-new', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now, + actualScore: 11, + actualReps: 6, + actualTimeMs: 12000, + ), + _historySetResult( + id: 'progression-new-chrono-slow', + historyId: 'progression-series-new', + sourceExerciseId: 'exercise-1', + setIndex: 1, + startedAt: now, + scoreInputMode: ScoreInputMode.stopwatch, + actualScoreTimeMs: 45000, + ), + _historySetResult( + id: 'progression-new-chrono-fast', + historyId: 'progression-series-new', + sourceExerciseId: 'exercise-1', + setIndex: 2, + startedAt: now, + scoreInputMode: ScoreInputMode.stopwatch, + actualScoreTimeMs: 42000, + ), + ], + ), + ); + final range = ProgressionDateRange( + startedAt: now.subtract(const Duration(days: 7)), + endedAt: now, + ); + + final score = await progressionStatsRepository.readExerciseSeries( + range: range, + exerciseKey: 'exercise-1', + measure: ProgressionMeasure.manualScore, + ); + final chrono = await progressionStatsRepository.readExerciseSeries( + range: range, + exerciseKey: 'exercise-1', + measure: ProgressionMeasure.stopwatchScore, + ); + final reps = await progressionStatsRepository.readExerciseSeries( + range: range, + exerciseKey: 'exercise-1', + measure: ProgressionMeasure.reps, + ); + final time = await progressionStatsRepository.readExerciseSeries( + range: range, + exerciseKey: 'exercise-1', + measure: ProgressionMeasure.time, + ); + + expect(score.points.map((point) => point.rawValue), [9.0, 11.0]); + expect(chrono.points.single.rawValue, 42000); + expect(reps.points.map((point) => point.rawValue), [4, 6]); + expect(time.points.map((point) => point.rawValue), [10000, 12000]); + expect(score.points.first.workoutHistoryId, 'progression-series-old'); + }, + ); + + test('progression reports all-time data outside selected period', () async { + final now = DateTime.utc(2026, 7, 22, 16); + await historyRepository.save( + _history( + id: 'progression-all-time', + startedAt: now.subtract(const Duration(days: 60)), + result: _historySetResult( + id: 'progression-all-time-score', + historyId: 'progression-all-time', + sourceExerciseId: 'exercise-1', + setIndex: 0, + startedAt: now.subtract(const Duration(days: 60)), + actualScore: 10, + ), + ), + ); + + final series = await progressionStatsRepository.readExerciseSeries( + range: ProgressionDateRange( + startedAt: now.subtract(const Duration(days: 7)), + endedAt: now, + ), + exerciseKey: 'exercise-1', + measure: ProgressionMeasure.manualScore, + ); + + expect(series.points, isEmpty); + expect(series.hasAnyAllTimeData, isTrue); + }); + test( 'performance reference use case prioritizes record score metric', () async { @@ -1157,14 +1438,16 @@ WorkoutHistory _history({ required DateTime startedAt, WorkoutHistorySetResult? result, List? results, + bool completed = true, + int totalActiveMs = 300000, }) { return WorkoutHistory( metadata: _metadata(id, startedAt), nameSnapshot: id, startedAt: startedAt, endedAt: startedAt.add(const Duration(minutes: 5)), - totalActiveMs: 300000, - completed: true, + totalActiveMs: totalActiveMs, + completed: completed, historySnapshotJson: '{"name":"$id"}', results: results ?? [result!], ); diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 172ee3f..292d4f8 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -212,6 +212,10 @@ final class _FakeBootstrap implements AppDependencies { repository: _FakeWorkoutHistoryRepository(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ), + progressionStatsUseCase = ProgressionStatsUseCase( + repository: _FakeProgressionStatsRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ), exercisePerformanceReferenceUseCase = ExercisePerformanceReferenceUseCase( repository: _FakeExercisePerformanceReferenceRepository(), ); @@ -249,6 +253,9 @@ final class _FakeBootstrap implements AppDependencies { @override final WorkoutHistoryUseCases workoutHistoryUseCases; + @override + final ProgressionStatsUseCase progressionStatsUseCase; + @override final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; } @@ -711,6 +718,56 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { Future saveStepResult(WorkoutHistoryStepResult result) async {} } +final class _FakeProgressionStatsRepository + implements ProgressionStatsRepository { + @override + Future readGlobalStats( + ProgressionDateRange range, + ) async { + return const ProgressionGlobalStatsData( + completedSessionCount: 0, + totalActiveMs: 0, + activeWeekStarts: [], + hasAnyCompletedHistory: false, + ); + } + + @override + Future> listExerciseOptions( + ProgressionDateRange range, + ) async { + return const []; + } + + @override + Future> listMeasureOptions({ + required ProgressionDateRange range, + required String exerciseKey, + }) async { + return const []; + } + + @override + Future readExerciseSeries({ + required ProgressionDateRange range, + required String exerciseKey, + required ProgressionMeasure measure, + }) async { + return ProgressionExerciseSeriesData( + exerciseKey: exerciseKey, + exerciseNameSnapshot: exerciseKey, + measure: ProgressionMeasureOptionData( + measure: measure, + label: 'Score', + lowerIsBetter: false, + ), + points: const [], + hasAnyAllTimeData: false, + hasAnyCompletedExerciseResult: false, + ); + } +} + final class _FakeExercisePerformanceReferenceRepository implements ExercisePerformanceReferenceRepository { @override