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

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