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

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