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