feat(statistiques): implémentation backend statistiques de progression (ticket #82)

Lots B1+B2+B3 : schéma/domaine, requêtes d'agrégation et ports/use cases pour les statistiques de progression, avec tests associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 08:35:47 +02:00
parent fdb301f746
commit 520f227e82
8 changed files with 1538 additions and 7 deletions

View File

@ -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,
),

View File

@ -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<DateTime> 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<ProgressionPointData> 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<ProgressionGlobalStatsData> readGlobalStats(
ProgressionDateRange range,
);
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
ProgressionDateRange range,
);
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
required ProgressionDateRange range,
required String exerciseKey,
});
Future<ProgressionExerciseSeriesData> 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<ProgressionExerciseOption> 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<ProgressionMeasureOption> 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<ProgressionPoint> 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<int> readAppliedStarterSeedVersion();
Future<void> writeAppliedStarterSeedVersion(int version, DateTime appliedAt);

View File

@ -3440,6 +3440,220 @@ final class WorkoutHistoryUseCases {
Future<void> 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<ProgressionOverview> getOverview(ProgressionPeriod period) async {
final range = _progressionDateRange(period, clock.now());
final global = await repository.readGlobalStats(range);
final exerciseData = await repository.listExerciseOptions(range);
final exerciseOptions = <ProgressionExerciseOption>[];
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<ProgressionExerciseSeries> 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<ProgressionPoint> 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<ProgressionPoint> 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<int>(0, (total, value) => total + value);
return ProgressionSeriesSummary(
kind: ProgressionSeriesSummaryKind.totals,
recentTotal: recentTotal,
lastSessionTotal: points.last.rawValue,
);
}
}
enum SetPositionStatus { pending, completed, skipped }
final class SetResultPositionState {

View File

@ -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 '

View File

@ -1734,6 +1734,510 @@ LIMIT 1
}
}
final class DriftProgressionStatsRepository
implements ProgressionStatsRepository {
const DriftProgressionStatsRepository(this.database);
final db.AppDatabase database;
@override
Future<ProgressionGlobalStatsData> 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<List<ProgressionExerciseOptionData>> 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<List<ProgressionMeasureOptionData>> 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<String>(exerciseKey),
],
)
.get();
return rows.map(_progressionMeasureOptionFromRow).toList();
}
@override
Future<ProgressionExerciseSeriesData> 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<List<_ProgressionPointRow>> _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<String>(exerciseKey),
],
)
.get();
return rows.map((row) => _progressionPointRow(row, measure)).toList();
}
Future<bool> _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<String>(exerciseKey),
],
)
.getSingleOrNull();
return row != null;
}
}
List<Variable<Object>> _rangeVariables(ProgressionDateRange range) {
return [
Variable<DateTime>(range.endedAt.toUtc()),
if (range.startedAt != null) Variable<DateTime>(range.startedAt!.toUtc()),
];
}
List<Variable<Object>> _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<void> _upsertWithChangeLog({
required db.AppDatabase database,
required String tableName,

View File

@ -2255,6 +2255,168 @@ void main() {
throwsA(isA<DomainException>()),
);
});
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<ProgressionExerciseOptionData> exerciseOptions = const [];
final measureOptions = <String, List<ProgressionMeasureOptionData>>{};
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<ProgressionGlobalStatsData> readGlobalStats(
ProgressionDateRange range,
) async {
lastGlobalRange = range;
return globalStats;
}
@override
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
ProgressionDateRange range,
) async {
return exerciseOptions;
}
@override
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
required ProgressionDateRange range,
required String exerciseKey,
}) async {
return measureOptions[exerciseKey] ?? const [];
}
@override
Future<ProgressionExerciseSeriesData> 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'),

View File

@ -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<WorkoutHistorySetResult>? 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!],
);

View File

@ -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<void> saveStepResult(WorkoutHistoryStepResult result) async {}
}
final class _FakeProgressionStatsRepository
implements ProgressionStatsRepository {
@override
Future<ProgressionGlobalStatsData> readGlobalStats(
ProgressionDateRange range,
) async {
return const ProgressionGlobalStatsData(
completedSessionCount: 0,
totalActiveMs: 0,
activeWeekStarts: [],
hasAnyCompletedHistory: false,
);
}
@override
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
ProgressionDateRange range,
) async {
return const [];
}
@override
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
required ProgressionDateRange range,
required String exerciseKey,
}) async {
return const [];
}
@override
Future<ProgressionExerciseSeriesData> 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