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