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:
@ -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!],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user