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