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

@ -13,6 +13,7 @@ abstract interface class AppDependencies {
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
WorkoutHistoryUseCases get workoutHistoryUseCases;
ProgressionStatsUseCase get progressionStatsUseCase;
ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase;
SyncUseCases get syncUseCases;
ShareUseCases get shareUseCases;
@ -30,6 +31,7 @@ final class AppBootstrap implements AppDependencies {
required this.activeExerciseStepUseCases,
required this.closeWorkoutSessionUseCase,
required this.workoutHistoryUseCases,
required this.progressionStatsUseCase,
required this.exercisePerformanceReferenceUseCase,
required this.syncUseCases,
required this.shareUseCases,
@ -56,6 +58,8 @@ final class AppBootstrap implements AppDependencies {
@override
final WorkoutHistoryUseCases workoutHistoryUseCases;
@override
final ProgressionStatsUseCase progressionStatsUseCase;
@override
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
@override
final SyncUseCases syncUseCases;
@ -81,6 +85,9 @@ final class AppBootstrap implements AppDependencies {
final templateRepository = DriftWorkoutTemplateRepository(database);
final activeSessionRepository = DriftActiveSessionRepository(database);
final historyRepository = DriftWorkoutHistoryRepository(database);
final progressionStatsRepository = DriftProgressionStatsRepository(
database,
);
final performanceReferenceRepository =
DriftExercisePerformanceReferenceRepository(database);
final ids = LocalIdGenerator();
@ -165,6 +172,10 @@ final class AppBootstrap implements AppDependencies {
repository: historyRepository,
clock: clock,
),
progressionStatsUseCase: ProgressionStatsUseCase(
repository: progressionStatsRepository,
clock: clock,
),
exercisePerformanceReferenceUseCase: ExercisePerformanceReferenceUseCase(
repository: performanceReferenceRepository,
),

View File

@ -27,6 +27,232 @@ final class StarterContent {
final WorkoutTemplate workoutTemplate;
}
enum ProgressionPeriod { fourWeeks, threeMonths, all }
enum ProgressionMeasure { manualScore, stopwatchScore, reps, time }
enum ProgressionRangeLabelKind { sinceDate, sinceFirstSession, none }
enum ProgressionSeriesState {
ready,
singlePoint,
emptyForPeriod,
emptyButHasAllTimeData,
noGraphableMeasure,
textScoreOnly,
}
enum ProgressionSeriesSummaryKind { bestLast, totals, none }
final class ProgressionDateRange {
const ProgressionDateRange({required this.startedAt, required this.endedAt});
final DateTime? startedAt;
final DateTime endedAt;
}
final class ProgressionGlobalStatsData {
const ProgressionGlobalStatsData({
required this.completedSessionCount,
required this.totalActiveMs,
required this.activeWeekStarts,
required this.hasAnyCompletedHistory,
this.firstCompletedSessionAt,
});
final int completedSessionCount;
final int totalActiveMs;
final List<DateTime> activeWeekStarts;
final bool hasAnyCompletedHistory;
final DateTime? firstCompletedSessionAt;
}
final class ProgressionExerciseOptionData {
const ProgressionExerciseOptionData({
required this.exerciseKey,
required this.nameSnapshot,
required this.isArchived,
required this.lastPerformedAt,
});
final String exerciseKey;
final String nameSnapshot;
final bool isArchived;
final DateTime lastPerformedAt;
}
final class ProgressionMeasureOptionData {
const ProgressionMeasureOptionData({
required this.measure,
required this.label,
this.scoreLabel,
this.scoreUnit,
required this.lowerIsBetter,
});
final ProgressionMeasure measure;
final String label;
final String? scoreLabel;
final String? scoreUnit;
final bool lowerIsBetter;
}
final class ProgressionExerciseSeriesData {
const ProgressionExerciseSeriesData({
required this.exerciseKey,
required this.exerciseNameSnapshot,
required this.measure,
required this.points,
required this.hasAnyAllTimeData,
required this.hasAnyCompletedExerciseResult,
});
final String exerciseKey;
final String exerciseNameSnapshot;
final ProgressionMeasureOptionData measure;
final List<ProgressionPointData> points;
final bool hasAnyAllTimeData;
final bool hasAnyCompletedExerciseResult;
}
final class ProgressionPointData {
const ProgressionPointData({
required this.workoutHistoryId,
required this.workoutNameSnapshot,
required this.startedAt,
required this.value,
required this.rawValue,
});
final String workoutHistoryId;
final String workoutNameSnapshot;
final DateTime startedAt;
final double value;
final Object rawValue;
}
abstract interface class ProgressionStatsRepository {
Future<ProgressionGlobalStatsData> readGlobalStats(
ProgressionDateRange range,
);
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
ProgressionDateRange range,
);
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
required ProgressionDateRange range,
required String exerciseKey,
});
Future<ProgressionExerciseSeriesData> readExerciseSeries({
required ProgressionDateRange range,
required String exerciseKey,
required ProgressionMeasure measure,
});
}
final class ProgressionOverview {
const ProgressionOverview({
required this.period,
required this.rangeLabelKind,
required this.completedSessionCount,
required this.totalActiveMs,
required this.activeWeekCount,
required this.totalWeekCount,
required this.hasAnyCompletedHistory,
required this.exerciseOptions,
});
final ProgressionPeriod period;
final ProgressionRangeLabelKind rangeLabelKind;
final int completedSessionCount;
final int totalActiveMs;
final int activeWeekCount;
final int? totalWeekCount;
final bool hasAnyCompletedHistory;
final List<ProgressionExerciseOption> exerciseOptions;
}
final class ProgressionExerciseOption {
const ProgressionExerciseOption({
required this.exerciseKey,
required this.nameSnapshot,
required this.isArchived,
required this.lastPerformedAt,
required this.measures,
});
final String exerciseKey;
final String nameSnapshot;
final bool isArchived;
final DateTime lastPerformedAt;
final List<ProgressionMeasureOption> measures;
}
final class ProgressionMeasureOption {
const ProgressionMeasureOption({
required this.measure,
required this.label,
this.scoreLabel,
this.scoreUnit,
required this.lowerIsBetter,
});
final ProgressionMeasure measure;
final String label;
final String? scoreLabel;
final String? scoreUnit;
final bool lowerIsBetter;
}
final class ProgressionExerciseSeries {
const ProgressionExerciseSeries({
required this.exerciseKey,
required this.exerciseNameSnapshot,
required this.measure,
required this.points,
required this.summary,
required this.state,
});
final String exerciseKey;
final String exerciseNameSnapshot;
final ProgressionMeasureOption measure;
final List<ProgressionPoint> points;
final ProgressionSeriesSummary summary;
final ProgressionSeriesState state;
}
final class ProgressionPoint {
const ProgressionPoint({
required this.workoutHistoryId,
required this.workoutNameSnapshot,
required this.startedAt,
required this.value,
required this.rawValue,
});
final String workoutHistoryId;
final String workoutNameSnapshot;
final DateTime startedAt;
final double value;
final Object rawValue;
}
final class ProgressionSeriesSummary {
const ProgressionSeriesSummary({
required this.kind,
this.best,
this.last,
this.recentTotal,
this.lastSessionTotal,
});
final ProgressionSeriesSummaryKind kind;
final Object? best;
final Object? last;
final Object? recentTotal;
final Object? lastSessionTotal;
}
abstract interface class StarterSeedStateRepository {
Future<int> readAppliedStarterSeedVersion();
Future<void> writeAppliedStarterSeedVersion(int version, DateTime appliedAt);

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 {