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

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