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;
|
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
|
||||||
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
|
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
|
||||||
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
||||||
|
ProgressionStatsUseCase get progressionStatsUseCase;
|
||||||
ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase;
|
ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase;
|
||||||
SyncUseCases get syncUseCases;
|
SyncUseCases get syncUseCases;
|
||||||
ShareUseCases get shareUseCases;
|
ShareUseCases get shareUseCases;
|
||||||
@ -30,6 +31,7 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
required this.activeExerciseStepUseCases,
|
required this.activeExerciseStepUseCases,
|
||||||
required this.closeWorkoutSessionUseCase,
|
required this.closeWorkoutSessionUseCase,
|
||||||
required this.workoutHistoryUseCases,
|
required this.workoutHistoryUseCases,
|
||||||
|
required this.progressionStatsUseCase,
|
||||||
required this.exercisePerformanceReferenceUseCase,
|
required this.exercisePerformanceReferenceUseCase,
|
||||||
required this.syncUseCases,
|
required this.syncUseCases,
|
||||||
required this.shareUseCases,
|
required this.shareUseCases,
|
||||||
@ -56,6 +58,8 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
@override
|
@override
|
||||||
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
||||||
@override
|
@override
|
||||||
|
final ProgressionStatsUseCase progressionStatsUseCase;
|
||||||
|
@override
|
||||||
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
|
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
|
||||||
@override
|
@override
|
||||||
final SyncUseCases syncUseCases;
|
final SyncUseCases syncUseCases;
|
||||||
@ -81,6 +85,9 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
final templateRepository = DriftWorkoutTemplateRepository(database);
|
final templateRepository = DriftWorkoutTemplateRepository(database);
|
||||||
final activeSessionRepository = DriftActiveSessionRepository(database);
|
final activeSessionRepository = DriftActiveSessionRepository(database);
|
||||||
final historyRepository = DriftWorkoutHistoryRepository(database);
|
final historyRepository = DriftWorkoutHistoryRepository(database);
|
||||||
|
final progressionStatsRepository = DriftProgressionStatsRepository(
|
||||||
|
database,
|
||||||
|
);
|
||||||
final performanceReferenceRepository =
|
final performanceReferenceRepository =
|
||||||
DriftExercisePerformanceReferenceRepository(database);
|
DriftExercisePerformanceReferenceRepository(database);
|
||||||
final ids = LocalIdGenerator();
|
final ids = LocalIdGenerator();
|
||||||
@ -165,6 +172,10 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
repository: historyRepository,
|
repository: historyRepository,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
),
|
),
|
||||||
|
progressionStatsUseCase: ProgressionStatsUseCase(
|
||||||
|
repository: progressionStatsRepository,
|
||||||
|
clock: clock,
|
||||||
|
),
|
||||||
exercisePerformanceReferenceUseCase: ExercisePerformanceReferenceUseCase(
|
exercisePerformanceReferenceUseCase: ExercisePerformanceReferenceUseCase(
|
||||||
repository: performanceReferenceRepository,
|
repository: performanceReferenceRepository,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -27,6 +27,232 @@ final class StarterContent {
|
|||||||
final WorkoutTemplate workoutTemplate;
|
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 {
|
abstract interface class StarterSeedStateRepository {
|
||||||
Future<int> readAppliedStarterSeedVersion();
|
Future<int> readAppliedStarterSeedVersion();
|
||||||
Future<void> writeAppliedStarterSeedVersion(int version, DateTime appliedAt);
|
Future<void> writeAppliedStarterSeedVersion(int version, DateTime appliedAt);
|
||||||
|
|||||||
@ -3440,6 +3440,220 @@ final class WorkoutHistoryUseCases {
|
|||||||
Future<void> delete(String id) => repository.delete(id, clock.now());
|
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 }
|
enum SetPositionStatus { pending, completed, skipped }
|
||||||
|
|
||||||
final class SetResultPositionState {
|
final class SetResultPositionState {
|
||||||
|
|||||||
@ -48,7 +48,7 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 17;
|
int get schemaVersion => 18;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@ -228,10 +228,21 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_workout_history_started_at '
|
'CREATE INDEX IF NOT EXISTS idx_workout_history_started_at '
|
||||||
'ON workout_history (started_at)',
|
'ON workout_history (started_at)',
|
||||||
);
|
);
|
||||||
|
await customStatement(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_workout_history_completed_started '
|
||||||
|
'ON workout_history (completed, started_at) '
|
||||||
|
'WHERE deleted_at IS NULL',
|
||||||
|
);
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
|
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
|
||||||
'ON workout_history_set_results (workout_history_id)',
|
'ON workout_history_set_results (workout_history_id)',
|
||||||
);
|
);
|
||||||
|
await customStatement(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_history_set_progression_exercise '
|
||||||
|
'ON workout_history_set_results (source_exercise_id_snapshot, '
|
||||||
|
'exercise_snapshot_id, workout_history_id, status) '
|
||||||
|
'WHERE deleted_at IS NULL',
|
||||||
|
);
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'CREATE INDEX IF NOT EXISTS '
|
'CREATE INDEX IF NOT EXISTS '
|
||||||
'idx_workout_history_set_results_source_exercise '
|
'idx_workout_history_set_results_source_exercise '
|
||||||
@ -243,6 +254,12 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id '
|
'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id '
|
||||||
'ON workout_history_step_results (workout_history_id)',
|
'ON workout_history_step_results (workout_history_id)',
|
||||||
);
|
);
|
||||||
|
await customStatement(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_history_step_progression_exercise '
|
||||||
|
'ON workout_history_step_results (source_exercise_id_snapshot, '
|
||||||
|
'exercise_snapshot_id, workout_history_id, status) '
|
||||||
|
'WHERE deleted_at IS NULL',
|
||||||
|
);
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'CREATE INDEX IF NOT EXISTS '
|
'CREATE INDEX IF NOT EXISTS '
|
||||||
'idx_workout_history_step_results_source_exercise '
|
'idx_workout_history_step_results_source_exercise '
|
||||||
|
|||||||
@ -1734,6 +1734,510 @@ LIMIT 1
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class DriftProgressionStatsRepository
|
||||||
|
implements ProgressionStatsRepository {
|
||||||
|
const DriftProgressionStatsRepository(this.database);
|
||||||
|
|
||||||
|
final db.AppDatabase database;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProgressionGlobalStatsData> readGlobalStats(
|
||||||
|
ProgressionDateRange range,
|
||||||
|
) async {
|
||||||
|
final variables = _rangeVariables(range);
|
||||||
|
final row = await database.customSelect('''
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS completed_count,
|
||||||
|
COALESCE(SUM(total_active_ms), 0) AS total_active_ms,
|
||||||
|
MIN(started_at) AS first_started_at
|
||||||
|
FROM workout_history
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND completed = 1
|
||||||
|
AND started_at <= ?
|
||||||
|
${range.startedAt == null ? '' : 'AND started_at >= ?'}
|
||||||
|
''', variables: variables).getSingle();
|
||||||
|
final anyRow = await database.customSelect('''
|
||||||
|
SELECT MIN(started_at) AS first_started_at
|
||||||
|
FROM workout_history
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND completed = 1
|
||||||
|
''').getSingle();
|
||||||
|
final weekRows = await database.customSelect('''
|
||||||
|
SELECT started_at
|
||||||
|
FROM workout_history
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND completed = 1
|
||||||
|
AND started_at <= ?
|
||||||
|
${range.startedAt == null ? '' : 'AND started_at >= ?'}
|
||||||
|
''', variables: variables).get();
|
||||||
|
final weekStarts = {
|
||||||
|
for (final row in weekRows)
|
||||||
|
_startOfLocalWeekFromUtc(_dateTimeFromData(row.data, 'started_at')),
|
||||||
|
}.toList()..sort();
|
||||||
|
return ProgressionGlobalStatsData(
|
||||||
|
completedSessionCount: row.data['completed_count'] as int,
|
||||||
|
totalActiveMs: row.data['total_active_ms'] as int,
|
||||||
|
activeWeekStarts: weekStarts,
|
||||||
|
hasAnyCompletedHistory: anyRow.data['first_started_at'] != null,
|
||||||
|
firstCompletedSessionAt: _dateTimeOrNullFromData(
|
||||||
|
anyRow.data,
|
||||||
|
'first_started_at',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
|
||||||
|
ProgressionDateRange range,
|
||||||
|
) async {
|
||||||
|
final rows = await database.customSelect('''
|
||||||
|
WITH result_projection AS (
|
||||||
|
${_progressionResultProjection(range)}
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
exercise_key,
|
||||||
|
exercise_name_snapshot,
|
||||||
|
source_exercise_id_snapshot,
|
||||||
|
started_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY exercise_key
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
) AS name_rank
|
||||||
|
FROM result_projection
|
||||||
|
WHERE has_graphable_measure = 1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ranked.exercise_key,
|
||||||
|
MAX(CASE WHEN ranked.name_rank = 1 THEN ranked.exercise_name_snapshot END)
|
||||||
|
AS exercise_name_snapshot,
|
||||||
|
MAX(ranked.started_at) AS last_performed_at,
|
||||||
|
CASE
|
||||||
|
WHEN exercises.id IS NOT NULL
|
||||||
|
AND exercises.deleted_at IS NULL
|
||||||
|
AND exercises.archived_at IS NULL
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END AS is_archived
|
||||||
|
FROM ranked
|
||||||
|
LEFT JOIN exercises
|
||||||
|
ON exercises.id = ranked.source_exercise_id_snapshot
|
||||||
|
GROUP BY ranked.exercise_key
|
||||||
|
ORDER BY last_performed_at DESC
|
||||||
|
''', variables: _projectionVariables(range)).get();
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(row) => ProgressionExerciseOptionData(
|
||||||
|
exerciseKey: row.data['exercise_key'] as String,
|
||||||
|
nameSnapshot: row.data['exercise_name_snapshot'] as String,
|
||||||
|
isArchived: (row.data['is_archived'] as int) == 1,
|
||||||
|
lastPerformedAt: _dateTimeFromData(row.data, 'last_performed_at'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
|
||||||
|
required ProgressionDateRange range,
|
||||||
|
required String exerciseKey,
|
||||||
|
}) async {
|
||||||
|
final rows = await database
|
||||||
|
.customSelect(
|
||||||
|
'''
|
||||||
|
WITH result_projection AS (
|
||||||
|
${_progressionResultProjection(range)}
|
||||||
|
),
|
||||||
|
measure_rows AS (
|
||||||
|
SELECT 0 AS sort_order, 'manualScore' AS measure, score_label_snapshot,
|
||||||
|
score_unit_snapshot, started_at
|
||||||
|
FROM result_projection
|
||||||
|
WHERE exercise_key = ?
|
||||||
|
AND score_input_mode_snapshot = 'manual'
|
||||||
|
AND actual_score IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1, 'stopwatchScore', NULL, NULL, started_at
|
||||||
|
FROM result_projection
|
||||||
|
WHERE exercise_key = ?
|
||||||
|
AND score_input_mode_snapshot = 'stopwatch'
|
||||||
|
AND actual_score_time_ms IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 2, 'reps', NULL, NULL, started_at
|
||||||
|
FROM result_projection
|
||||||
|
WHERE exercise_key = ?
|
||||||
|
AND actual_reps IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 3, 'time', NULL, NULL, started_at
|
||||||
|
FROM result_projection
|
||||||
|
WHERE exercise_key = ?
|
||||||
|
AND actual_time_ms IS NOT NULL
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY measure ORDER BY started_at DESC)
|
||||||
|
AS value_rank
|
||||||
|
FROM measure_rows
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
measure,
|
||||||
|
MAX(CASE WHEN value_rank = 1 THEN score_label_snapshot END) AS score_label,
|
||||||
|
MAX(CASE WHEN value_rank = 1 THEN score_unit_snapshot END) AS score_unit,
|
||||||
|
MIN(sort_order) AS sort_order
|
||||||
|
FROM ranked
|
||||||
|
GROUP BY measure
|
||||||
|
ORDER BY sort_order
|
||||||
|
''',
|
||||||
|
variables: [
|
||||||
|
..._projectionVariables(range),
|
||||||
|
for (var i = 0; i < 4; i++) Variable<String>(exerciseKey),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
return rows.map(_progressionMeasureOptionFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProgressionExerciseSeriesData> readExerciseSeries({
|
||||||
|
required ProgressionDateRange range,
|
||||||
|
required String exerciseKey,
|
||||||
|
required ProgressionMeasure measure,
|
||||||
|
}) async {
|
||||||
|
final measureOption =
|
||||||
|
(await listMeasureOptions(
|
||||||
|
range: range,
|
||||||
|
exerciseKey: exerciseKey,
|
||||||
|
)).where((option) => option.measure == measure).firstOrNull ??
|
||||||
|
_defaultProgressionMeasureOption(measure);
|
||||||
|
final points = await _readExercisePoints(
|
||||||
|
range: range,
|
||||||
|
exerciseKey: exerciseKey,
|
||||||
|
measure: measure,
|
||||||
|
);
|
||||||
|
final allTimePoints = range.startedAt == null
|
||||||
|
? points
|
||||||
|
: await _readExercisePoints(
|
||||||
|
range: ProgressionDateRange(
|
||||||
|
startedAt: null,
|
||||||
|
endedAt: range.endedAt,
|
||||||
|
),
|
||||||
|
exerciseKey: exerciseKey,
|
||||||
|
measure: measure,
|
||||||
|
);
|
||||||
|
final exerciseName = points.isNotEmpty
|
||||||
|
? points.last.exerciseNameSnapshot
|
||||||
|
: allTimePoints.isNotEmpty
|
||||||
|
? allTimePoints.last.exerciseNameSnapshot
|
||||||
|
: exerciseKey;
|
||||||
|
final hasAnyCompletedExerciseResult = await _hasAnyCompletedExerciseResult(
|
||||||
|
range: range,
|
||||||
|
exerciseKey: exerciseKey,
|
||||||
|
);
|
||||||
|
return ProgressionExerciseSeriesData(
|
||||||
|
exerciseKey: exerciseKey,
|
||||||
|
exerciseNameSnapshot: exerciseName,
|
||||||
|
measure: measureOption,
|
||||||
|
points: [
|
||||||
|
for (final point in points)
|
||||||
|
ProgressionPointData(
|
||||||
|
workoutHistoryId: point.workoutHistoryId,
|
||||||
|
workoutNameSnapshot: point.workoutNameSnapshot,
|
||||||
|
startedAt: point.startedAt,
|
||||||
|
value: point.value,
|
||||||
|
rawValue: point.rawValue,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
hasAnyAllTimeData: allTimePoints.isNotEmpty,
|
||||||
|
hasAnyCompletedExerciseResult: hasAnyCompletedExerciseResult,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<_ProgressionPointRow>> _readExercisePoints({
|
||||||
|
required ProgressionDateRange range,
|
||||||
|
required String exerciseKey,
|
||||||
|
required ProgressionMeasure measure,
|
||||||
|
}) async {
|
||||||
|
final aggregation = _progressionAggregationSql(measure);
|
||||||
|
final rows = await database
|
||||||
|
.customSelect(
|
||||||
|
'''
|
||||||
|
WITH result_projection AS (
|
||||||
|
${_progressionResultProjection(range)}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
workout_history_id,
|
||||||
|
workout_name_snapshot,
|
||||||
|
MAX(started_at) AS started_at,
|
||||||
|
MAX(exercise_name_snapshot) AS exercise_name_snapshot,
|
||||||
|
${aggregation.expression} AS raw_value
|
||||||
|
FROM result_projection
|
||||||
|
WHERE exercise_key = ?
|
||||||
|
AND ${aggregation.predicate}
|
||||||
|
GROUP BY workout_history_id, workout_name_snapshot
|
||||||
|
ORDER BY started_at ASC
|
||||||
|
''',
|
||||||
|
variables: [
|
||||||
|
..._projectionVariables(range),
|
||||||
|
Variable<String>(exerciseKey),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
return rows.map((row) => _progressionPointRow(row, measure)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _hasAnyCompletedExerciseResult({
|
||||||
|
required ProgressionDateRange range,
|
||||||
|
required String exerciseKey,
|
||||||
|
}) async {
|
||||||
|
final row = await database
|
||||||
|
.customSelect(
|
||||||
|
'''
|
||||||
|
WITH result_projection AS (
|
||||||
|
${_progressionResultProjection(range)}
|
||||||
|
)
|
||||||
|
SELECT 1
|
||||||
|
FROM result_projection
|
||||||
|
WHERE exercise_key = ?
|
||||||
|
LIMIT 1
|
||||||
|
''',
|
||||||
|
variables: [
|
||||||
|
..._projectionVariables(range),
|
||||||
|
Variable<String>(exerciseKey),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.getSingleOrNull();
|
||||||
|
return row != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Variable<Object>> _rangeVariables(ProgressionDateRange range) {
|
||||||
|
return [
|
||||||
|
Variable<DateTime>(range.endedAt.toUtc()),
|
||||||
|
if (range.startedAt != null) Variable<DateTime>(range.startedAt!.toUtc()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Variable<Object>> _projectionVariables(ProgressionDateRange range) {
|
||||||
|
final variables = _rangeVariables(range);
|
||||||
|
return [...variables, ...variables];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _progressionResultProjection(ProgressionDateRange range) {
|
||||||
|
final rangePredicate =
|
||||||
|
'history.started_at <= ? '
|
||||||
|
"${range.startedAt == null ? '' : 'AND history.started_at >= ?'}";
|
||||||
|
final sharedWhere =
|
||||||
|
'history.deleted_at IS NULL '
|
||||||
|
'AND history.completed = 1 '
|
||||||
|
'AND $rangePredicate';
|
||||||
|
return '''
|
||||||
|
SELECT
|
||||||
|
history.id AS workout_history_id,
|
||||||
|
history.name_snapshot AS workout_name_snapshot,
|
||||||
|
history.started_at,
|
||||||
|
COALESCE(result.source_exercise_id_snapshot, result.exercise_snapshot_id)
|
||||||
|
AS exercise_key,
|
||||||
|
result.source_exercise_id_snapshot,
|
||||||
|
result.exercise_snapshot_id,
|
||||||
|
result.exercise_name_snapshot,
|
||||||
|
result.score_input_mode_snapshot,
|
||||||
|
result.score_label_snapshot,
|
||||||
|
result.score_unit_snapshot,
|
||||||
|
result.actual_time_ms,
|
||||||
|
result.actual_reps,
|
||||||
|
result.actual_score,
|
||||||
|
result.actual_score_time_ms,
|
||||||
|
CASE
|
||||||
|
WHEN result.actual_time_ms IS NOT NULL
|
||||||
|
OR result.actual_reps IS NOT NULL
|
||||||
|
OR result.actual_score IS NOT NULL
|
||||||
|
OR result.actual_score_time_ms IS NOT NULL
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END AS has_graphable_measure
|
||||||
|
FROM workout_history_set_results AS result
|
||||||
|
INNER JOIN workout_history AS history
|
||||||
|
ON history.id = result.workout_history_id
|
||||||
|
WHERE result.deleted_at IS NULL
|
||||||
|
AND result.status = 'completed'
|
||||||
|
AND $sharedWhere
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
history.id AS workout_history_id,
|
||||||
|
history.name_snapshot AS workout_name_snapshot,
|
||||||
|
history.started_at,
|
||||||
|
COALESCE(step.source_exercise_id_snapshot, step.exercise_snapshot_id)
|
||||||
|
AS exercise_key,
|
||||||
|
step.source_exercise_id_snapshot,
|
||||||
|
step.exercise_snapshot_id,
|
||||||
|
COALESCE(parent.exercise_name_snapshot, step.step_name_snapshot)
|
||||||
|
AS exercise_name_snapshot,
|
||||||
|
step.score_input_mode_snapshot,
|
||||||
|
step.score_label_snapshot,
|
||||||
|
step.score_unit_snapshot,
|
||||||
|
step.actual_time_ms,
|
||||||
|
step.actual_reps,
|
||||||
|
step.actual_score,
|
||||||
|
step.actual_score_time_ms,
|
||||||
|
CASE
|
||||||
|
WHEN step.actual_time_ms IS NOT NULL
|
||||||
|
OR step.actual_reps IS NOT NULL
|
||||||
|
OR step.actual_score IS NOT NULL
|
||||||
|
OR step.actual_score_time_ms IS NOT NULL
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END AS has_graphable_measure
|
||||||
|
FROM workout_history_step_results AS step
|
||||||
|
INNER JOIN workout_history AS history
|
||||||
|
ON history.id = step.workout_history_id
|
||||||
|
LEFT JOIN workout_history_set_results AS parent
|
||||||
|
ON parent.workout_history_id = step.workout_history_id
|
||||||
|
AND parent.program_index = step.program_index
|
||||||
|
AND parent.exercise_index = step.exercise_index
|
||||||
|
AND parent.set_index = step.set_index
|
||||||
|
AND parent.deleted_at IS NULL
|
||||||
|
WHERE step.deleted_at IS NULL
|
||||||
|
AND step.status = 'completed'
|
||||||
|
AND $sharedWhere
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressionMeasureOptionData _progressionMeasureOptionFromRow(QueryRow row) {
|
||||||
|
final measure = _progressionMeasureFromDb(row.data['measure'] as String);
|
||||||
|
final defaults = _defaultProgressionMeasureOption(measure);
|
||||||
|
return ProgressionMeasureOptionData(
|
||||||
|
measure: measure,
|
||||||
|
label: defaults.label,
|
||||||
|
scoreLabel: row.data['score_label'] as String?,
|
||||||
|
scoreUnit: row.data['score_unit'] as String?,
|
||||||
|
lowerIsBetter: defaults.lowerIsBetter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressionMeasure _progressionMeasureFromDb(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'manualScore':
|
||||||
|
return ProgressionMeasure.manualScore;
|
||||||
|
case 'stopwatchScore':
|
||||||
|
return ProgressionMeasure.stopwatchScore;
|
||||||
|
case 'reps':
|
||||||
|
return ProgressionMeasure.reps;
|
||||||
|
case 'time':
|
||||||
|
return ProgressionMeasure.time;
|
||||||
|
}
|
||||||
|
throw domain.DomainException('Unknown progression measure: $value');
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressionMeasureOptionData _defaultProgressionMeasureOption(
|
||||||
|
ProgressionMeasure measure,
|
||||||
|
) {
|
||||||
|
switch (measure) {
|
||||||
|
case ProgressionMeasure.manualScore:
|
||||||
|
return const ProgressionMeasureOptionData(
|
||||||
|
measure: ProgressionMeasure.manualScore,
|
||||||
|
label: 'Score',
|
||||||
|
lowerIsBetter: false,
|
||||||
|
);
|
||||||
|
case ProgressionMeasure.stopwatchScore:
|
||||||
|
return const ProgressionMeasureOptionData(
|
||||||
|
measure: ProgressionMeasure.stopwatchScore,
|
||||||
|
label: 'Temps réalisé',
|
||||||
|
lowerIsBetter: true,
|
||||||
|
);
|
||||||
|
case ProgressionMeasure.reps:
|
||||||
|
return const ProgressionMeasureOptionData(
|
||||||
|
measure: ProgressionMeasure.reps,
|
||||||
|
label: 'Répétitions',
|
||||||
|
lowerIsBetter: false,
|
||||||
|
);
|
||||||
|
case ProgressionMeasure.time:
|
||||||
|
return const ProgressionMeasureOptionData(
|
||||||
|
measure: ProgressionMeasure.time,
|
||||||
|
label: 'Temps',
|
||||||
|
lowerIsBetter: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ProgressionAggregationSql _progressionAggregationSql(
|
||||||
|
ProgressionMeasure measure,
|
||||||
|
) {
|
||||||
|
switch (measure) {
|
||||||
|
case ProgressionMeasure.manualScore:
|
||||||
|
return const _ProgressionAggregationSql(
|
||||||
|
expression: 'MAX(actual_score)',
|
||||||
|
predicate:
|
||||||
|
"score_input_mode_snapshot = 'manual' AND actual_score IS NOT NULL",
|
||||||
|
);
|
||||||
|
case ProgressionMeasure.stopwatchScore:
|
||||||
|
return const _ProgressionAggregationSql(
|
||||||
|
expression: 'MIN(actual_score_time_ms)',
|
||||||
|
predicate:
|
||||||
|
"score_input_mode_snapshot = 'stopwatch' "
|
||||||
|
'AND actual_score_time_ms IS NOT NULL',
|
||||||
|
);
|
||||||
|
case ProgressionMeasure.reps:
|
||||||
|
return const _ProgressionAggregationSql(
|
||||||
|
expression: 'SUM(actual_reps)',
|
||||||
|
predicate: 'actual_reps IS NOT NULL',
|
||||||
|
);
|
||||||
|
case ProgressionMeasure.time:
|
||||||
|
return const _ProgressionAggregationSql(
|
||||||
|
expression: 'SUM(actual_time_ms)',
|
||||||
|
predicate: 'actual_time_ms IS NOT NULL',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ProgressionPointRow _progressionPointRow(
|
||||||
|
QueryRow row,
|
||||||
|
ProgressionMeasure measure,
|
||||||
|
) {
|
||||||
|
final raw = row.data['raw_value'] as num;
|
||||||
|
final rawValue = measure == ProgressionMeasure.manualScore
|
||||||
|
? raw.toDouble()
|
||||||
|
: raw.toInt();
|
||||||
|
return _ProgressionPointRow(
|
||||||
|
workoutHistoryId: row.data['workout_history_id'] as String,
|
||||||
|
workoutNameSnapshot: row.data['workout_name_snapshot'] as String,
|
||||||
|
exerciseNameSnapshot: row.data['exercise_name_snapshot'] as String,
|
||||||
|
startedAt: _dateTimeFromData(row.data, 'started_at'),
|
||||||
|
value: raw.toDouble(),
|
||||||
|
rawValue: rawValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _startOfLocalWeekFromUtc(DateTime value) {
|
||||||
|
final local = value.toLocal();
|
||||||
|
final localDay = DateTime(local.year, local.month, local.day);
|
||||||
|
return localDay.subtract(Duration(days: localDay.weekday - DateTime.monday));
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _ProgressionAggregationSql {
|
||||||
|
const _ProgressionAggregationSql({
|
||||||
|
required this.expression,
|
||||||
|
required this.predicate,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String expression;
|
||||||
|
final String predicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _ProgressionPointRow {
|
||||||
|
const _ProgressionPointRow({
|
||||||
|
required this.workoutHistoryId,
|
||||||
|
required this.workoutNameSnapshot,
|
||||||
|
required this.exerciseNameSnapshot,
|
||||||
|
required this.startedAt,
|
||||||
|
required this.value,
|
||||||
|
required this.rawValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String workoutHistoryId;
|
||||||
|
final String workoutNameSnapshot;
|
||||||
|
final String exerciseNameSnapshot;
|
||||||
|
final DateTime startedAt;
|
||||||
|
final double value;
|
||||||
|
final Object rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _upsertWithChangeLog({
|
Future<void> _upsertWithChangeLog({
|
||||||
required db.AppDatabase database,
|
required db.AppDatabase database,
|
||||||
required String tableName,
|
required String tableName,
|
||||||
|
|||||||
@ -2255,6 +2255,168 @@ void main() {
|
|||||||
throwsA(isA<DomainException>()),
|
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({
|
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({
|
AuthUseCases _authUseCase({
|
||||||
_FakeAuthTokenStore? tokenStore,
|
_FakeAuthTokenStore? tokenStore,
|
||||||
_FakeOnlineAccountRepository? accountRepository,
|
_FakeOnlineAccountRepository? accountRepository,
|
||||||
@ -3253,10 +3474,8 @@ ActiveWorkoutSession _sessionWithSnapshot({
|
|||||||
'exerciseStepsSnapshot': exerciseSteps
|
'exerciseStepsSnapshot': exerciseSteps
|
||||||
.map((step) => step.toSnapshotJson())
|
.map((step) => step.toSnapshotJson())
|
||||||
.toList(),
|
.toList(),
|
||||||
if (autoStartNextTimedStepSnapshot != null)
|
'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot,
|
||||||
'autoStartNextTimedStepSnapshot': autoStartNextTimedStepSnapshot,
|
'autoStartNextTimedStepOverride': ?autoStartNextTimedStepOverride,
|
||||||
if (autoStartNextTimedStepOverride != null)
|
|
||||||
'autoStartNextTimedStepOverride': autoStartNextTimedStepOverride,
|
|
||||||
};
|
};
|
||||||
return ActiveWorkoutSession(
|
return ActiveWorkoutSession(
|
||||||
metadata: _metadata('session-1'),
|
metadata: _metadata('session-1'),
|
||||||
|
|||||||
@ -14,6 +14,7 @@ void main() {
|
|||||||
late local.DriftActiveSessionRepository activeRepository;
|
late local.DriftActiveSessionRepository activeRepository;
|
||||||
late local.DriftWorkoutTemplateRepository templateRepository;
|
late local.DriftWorkoutTemplateRepository templateRepository;
|
||||||
late local.DriftWorkoutHistoryRepository historyRepository;
|
late local.DriftWorkoutHistoryRepository historyRepository;
|
||||||
|
late local.DriftProgressionStatsRepository progressionStatsRepository;
|
||||||
late local.DriftExercisePerformanceReferenceRepository
|
late local.DriftExercisePerformanceReferenceRepository
|
||||||
performanceReferenceRepository;
|
performanceReferenceRepository;
|
||||||
|
|
||||||
@ -24,6 +25,9 @@ void main() {
|
|||||||
activeRepository = local.DriftActiveSessionRepository(database);
|
activeRepository = local.DriftActiveSessionRepository(database);
|
||||||
templateRepository = local.DriftWorkoutTemplateRepository(database);
|
templateRepository = local.DriftWorkoutTemplateRepository(database);
|
||||||
historyRepository = local.DriftWorkoutHistoryRepository(database);
|
historyRepository = local.DriftWorkoutHistoryRepository(database);
|
||||||
|
progressionStatsRepository = local.DriftProgressionStatsRepository(
|
||||||
|
database,
|
||||||
|
);
|
||||||
performanceReferenceRepository =
|
performanceReferenceRepository =
|
||||||
local.DriftExercisePerformanceReferenceRepository(database);
|
local.DriftExercisePerformanceReferenceRepository(database);
|
||||||
});
|
});
|
||||||
@ -1086,6 +1090,283 @@ void main() {
|
|||||||
expect(latest!.actualReps, 13);
|
expect(latest!.actualReps, 13);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'progression global stats count active weeks and ignore inactive rows',
|
||||||
|
() async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 13);
|
||||||
|
await historyRepository.save(
|
||||||
|
_history(
|
||||||
|
id: 'progression-week-1',
|
||||||
|
startedAt: now.subtract(const Duration(days: 1)),
|
||||||
|
totalActiveMs: 120000,
|
||||||
|
result: _historySetResult(
|
||||||
|
id: 'progression-result-1',
|
||||||
|
historyId: 'progression-week-1',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now.subtract(const Duration(days: 1)),
|
||||||
|
actualReps: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await historyRepository.save(
|
||||||
|
_history(
|
||||||
|
id: 'progression-week-2',
|
||||||
|
startedAt: now.subtract(const Duration(days: 8)),
|
||||||
|
totalActiveMs: 180000,
|
||||||
|
result: _historySetResult(
|
||||||
|
id: 'progression-result-2',
|
||||||
|
historyId: 'progression-week-2',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now.subtract(const Duration(days: 8)),
|
||||||
|
actualReps: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await historyRepository.save(
|
||||||
|
_history(
|
||||||
|
id: 'progression-incomplete',
|
||||||
|
startedAt: now,
|
||||||
|
completed: false,
|
||||||
|
result: _historySetResult(
|
||||||
|
id: 'progression-result-incomplete',
|
||||||
|
historyId: 'progression-incomplete',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now,
|
||||||
|
actualReps: 99,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final stats = await progressionStatsRepository.readGlobalStats(
|
||||||
|
ProgressionDateRange(
|
||||||
|
startedAt: now.subtract(const Duration(days: 28)),
|
||||||
|
endedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stats.completedSessionCount, 2);
|
||||||
|
expect(stats.totalActiveMs, 300000);
|
||||||
|
expect(stats.activeWeekStarts, hasLength(2));
|
||||||
|
expect(stats.hasAnyCompletedHistory, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'progression lists exercises, archive state and measure options',
|
||||||
|
() async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 14);
|
||||||
|
await exerciseRepository.save(
|
||||||
|
Exercise(
|
||||||
|
metadata: _metadata('exercise-active', now),
|
||||||
|
name: 'Active drill',
|
||||||
|
hasTimeMeasure: false,
|
||||||
|
hasRepsMeasure: true,
|
||||||
|
hasScoreMeasure: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await exerciseRepository.save(
|
||||||
|
Exercise(
|
||||||
|
metadata: _metadata('exercise-archived', now),
|
||||||
|
name: 'Archived drill',
|
||||||
|
hasTimeMeasure: false,
|
||||||
|
hasRepsMeasure: true,
|
||||||
|
hasScoreMeasure: false,
|
||||||
|
archivedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await historyRepository.save(
|
||||||
|
_history(
|
||||||
|
id: 'progression-options',
|
||||||
|
startedAt: now,
|
||||||
|
results: [
|
||||||
|
_historySetResult(
|
||||||
|
id: 'progression-active-score',
|
||||||
|
historyId: 'progression-options',
|
||||||
|
sourceExerciseId: 'exercise-active',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now,
|
||||||
|
actualScore: 12,
|
||||||
|
),
|
||||||
|
_historySetResult(
|
||||||
|
id: 'progression-active-chrono',
|
||||||
|
historyId: 'progression-options',
|
||||||
|
sourceExerciseId: 'exercise-active',
|
||||||
|
setIndex: 1,
|
||||||
|
startedAt: now,
|
||||||
|
scoreInputMode: ScoreInputMode.stopwatch,
|
||||||
|
actualScoreTimeMs: 42000,
|
||||||
|
),
|
||||||
|
_historySetResult(
|
||||||
|
id: 'progression-archived-reps',
|
||||||
|
historyId: 'progression-options',
|
||||||
|
sourceExerciseId: 'exercise-archived',
|
||||||
|
setIndex: 2,
|
||||||
|
startedAt: now,
|
||||||
|
actualReps: 15,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final range = ProgressionDateRange(
|
||||||
|
startedAt: now.subtract(const Duration(days: 1)),
|
||||||
|
endedAt: now,
|
||||||
|
);
|
||||||
|
final options = await progressionStatsRepository.listExerciseOptions(
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
final activeMeasures = await progressionStatsRepository
|
||||||
|
.listMeasureOptions(range: range, exerciseKey: 'exercise-active');
|
||||||
|
|
||||||
|
expect(options.map((option) => option.exerciseKey), [
|
||||||
|
'exercise-active',
|
||||||
|
'exercise-archived',
|
||||||
|
]);
|
||||||
|
expect(options.first.isArchived, isFalse);
|
||||||
|
expect(options.last.isArchived, isTrue);
|
||||||
|
expect(activeMeasures.map((option) => option.measure), [
|
||||||
|
ProgressionMeasure.manualScore,
|
||||||
|
ProgressionMeasure.stopwatchScore,
|
||||||
|
]);
|
||||||
|
expect(activeMeasures.first.scoreLabel, 'Score');
|
||||||
|
expect(activeMeasures.first.scoreUnit, 'pts');
|
||||||
|
expect(activeMeasures.last.lowerIsBetter, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'progression exercise series aggregate each measure by session',
|
||||||
|
() async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 15);
|
||||||
|
await historyRepository.save(
|
||||||
|
_history(
|
||||||
|
id: 'progression-series-old',
|
||||||
|
startedAt: now.subtract(const Duration(days: 2)),
|
||||||
|
results: [
|
||||||
|
_historySetResult(
|
||||||
|
id: 'progression-old-score-low',
|
||||||
|
historyId: 'progression-series-old',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now.subtract(const Duration(days: 2)),
|
||||||
|
actualScore: 7,
|
||||||
|
),
|
||||||
|
_historySetResult(
|
||||||
|
id: 'progression-old-score-high',
|
||||||
|
historyId: 'progression-series-old',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 1,
|
||||||
|
startedAt: now.subtract(const Duration(days: 2)),
|
||||||
|
actualScore: 9,
|
||||||
|
actualReps: 4,
|
||||||
|
actualTimeMs: 10000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await historyRepository.save(
|
||||||
|
_history(
|
||||||
|
id: 'progression-series-new',
|
||||||
|
startedAt: now,
|
||||||
|
results: [
|
||||||
|
_historySetResult(
|
||||||
|
id: 'progression-new-score',
|
||||||
|
historyId: 'progression-series-new',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now,
|
||||||
|
actualScore: 11,
|
||||||
|
actualReps: 6,
|
||||||
|
actualTimeMs: 12000,
|
||||||
|
),
|
||||||
|
_historySetResult(
|
||||||
|
id: 'progression-new-chrono-slow',
|
||||||
|
historyId: 'progression-series-new',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 1,
|
||||||
|
startedAt: now,
|
||||||
|
scoreInputMode: ScoreInputMode.stopwatch,
|
||||||
|
actualScoreTimeMs: 45000,
|
||||||
|
),
|
||||||
|
_historySetResult(
|
||||||
|
id: 'progression-new-chrono-fast',
|
||||||
|
historyId: 'progression-series-new',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 2,
|
||||||
|
startedAt: now,
|
||||||
|
scoreInputMode: ScoreInputMode.stopwatch,
|
||||||
|
actualScoreTimeMs: 42000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final range = ProgressionDateRange(
|
||||||
|
startedAt: now.subtract(const Duration(days: 7)),
|
||||||
|
endedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final score = await progressionStatsRepository.readExerciseSeries(
|
||||||
|
range: range,
|
||||||
|
exerciseKey: 'exercise-1',
|
||||||
|
measure: ProgressionMeasure.manualScore,
|
||||||
|
);
|
||||||
|
final chrono = await progressionStatsRepository.readExerciseSeries(
|
||||||
|
range: range,
|
||||||
|
exerciseKey: 'exercise-1',
|
||||||
|
measure: ProgressionMeasure.stopwatchScore,
|
||||||
|
);
|
||||||
|
final reps = await progressionStatsRepository.readExerciseSeries(
|
||||||
|
range: range,
|
||||||
|
exerciseKey: 'exercise-1',
|
||||||
|
measure: ProgressionMeasure.reps,
|
||||||
|
);
|
||||||
|
final time = await progressionStatsRepository.readExerciseSeries(
|
||||||
|
range: range,
|
||||||
|
exerciseKey: 'exercise-1',
|
||||||
|
measure: ProgressionMeasure.time,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(score.points.map((point) => point.rawValue), [9.0, 11.0]);
|
||||||
|
expect(chrono.points.single.rawValue, 42000);
|
||||||
|
expect(reps.points.map((point) => point.rawValue), [4, 6]);
|
||||||
|
expect(time.points.map((point) => point.rawValue), [10000, 12000]);
|
||||||
|
expect(score.points.first.workoutHistoryId, 'progression-series-old');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('progression reports all-time data outside selected period', () async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 16);
|
||||||
|
await historyRepository.save(
|
||||||
|
_history(
|
||||||
|
id: 'progression-all-time',
|
||||||
|
startedAt: now.subtract(const Duration(days: 60)),
|
||||||
|
result: _historySetResult(
|
||||||
|
id: 'progression-all-time-score',
|
||||||
|
historyId: 'progression-all-time',
|
||||||
|
sourceExerciseId: 'exercise-1',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now.subtract(const Duration(days: 60)),
|
||||||
|
actualScore: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final series = await progressionStatsRepository.readExerciseSeries(
|
||||||
|
range: ProgressionDateRange(
|
||||||
|
startedAt: now.subtract(const Duration(days: 7)),
|
||||||
|
endedAt: now,
|
||||||
|
),
|
||||||
|
exerciseKey: 'exercise-1',
|
||||||
|
measure: ProgressionMeasure.manualScore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(series.points, isEmpty);
|
||||||
|
expect(series.hasAnyAllTimeData, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'performance reference use case prioritizes record score metric',
|
'performance reference use case prioritizes record score metric',
|
||||||
() async {
|
() async {
|
||||||
@ -1157,14 +1438,16 @@ WorkoutHistory _history({
|
|||||||
required DateTime startedAt,
|
required DateTime startedAt,
|
||||||
WorkoutHistorySetResult? result,
|
WorkoutHistorySetResult? result,
|
||||||
List<WorkoutHistorySetResult>? results,
|
List<WorkoutHistorySetResult>? results,
|
||||||
|
bool completed = true,
|
||||||
|
int totalActiveMs = 300000,
|
||||||
}) {
|
}) {
|
||||||
return WorkoutHistory(
|
return WorkoutHistory(
|
||||||
metadata: _metadata(id, startedAt),
|
metadata: _metadata(id, startedAt),
|
||||||
nameSnapshot: id,
|
nameSnapshot: id,
|
||||||
startedAt: startedAt,
|
startedAt: startedAt,
|
||||||
endedAt: startedAt.add(const Duration(minutes: 5)),
|
endedAt: startedAt.add(const Duration(minutes: 5)),
|
||||||
totalActiveMs: 300000,
|
totalActiveMs: totalActiveMs,
|
||||||
completed: true,
|
completed: completed,
|
||||||
historySnapshotJson: '{"name":"$id"}',
|
historySnapshotJson: '{"name":"$id"}',
|
||||||
results: results ?? [result!],
|
results: results ?? [result!],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -212,6 +212,10 @@ final class _FakeBootstrap implements AppDependencies {
|
|||||||
repository: _FakeWorkoutHistoryRepository(),
|
repository: _FakeWorkoutHistoryRepository(),
|
||||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
),
|
),
|
||||||
|
progressionStatsUseCase = ProgressionStatsUseCase(
|
||||||
|
repository: _FakeProgressionStatsRepository(),
|
||||||
|
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||||
|
),
|
||||||
exercisePerformanceReferenceUseCase = ExercisePerformanceReferenceUseCase(
|
exercisePerformanceReferenceUseCase = ExercisePerformanceReferenceUseCase(
|
||||||
repository: _FakeExercisePerformanceReferenceRepository(),
|
repository: _FakeExercisePerformanceReferenceRepository(),
|
||||||
);
|
);
|
||||||
@ -249,6 +253,9 @@ final class _FakeBootstrap implements AppDependencies {
|
|||||||
@override
|
@override
|
||||||
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
final WorkoutHistoryUseCases workoutHistoryUseCases;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ProgressionStatsUseCase progressionStatsUseCase;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
|
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
|
||||||
}
|
}
|
||||||
@ -711,6 +718,56 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
|||||||
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _FakeProgressionStatsRepository
|
||||||
|
implements ProgressionStatsRepository {
|
||||||
|
@override
|
||||||
|
Future<ProgressionGlobalStatsData> readGlobalStats(
|
||||||
|
ProgressionDateRange range,
|
||||||
|
) async {
|
||||||
|
return const ProgressionGlobalStatsData(
|
||||||
|
completedSessionCount: 0,
|
||||||
|
totalActiveMs: 0,
|
||||||
|
activeWeekStarts: [],
|
||||||
|
hasAnyCompletedHistory: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
|
||||||
|
ProgressionDateRange range,
|
||||||
|
) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
|
||||||
|
required ProgressionDateRange range,
|
||||||
|
required String exerciseKey,
|
||||||
|
}) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProgressionExerciseSeriesData> readExerciseSeries({
|
||||||
|
required ProgressionDateRange range,
|
||||||
|
required String exerciseKey,
|
||||||
|
required ProgressionMeasure measure,
|
||||||
|
}) async {
|
||||||
|
return ProgressionExerciseSeriesData(
|
||||||
|
exerciseKey: exerciseKey,
|
||||||
|
exerciseNameSnapshot: exerciseKey,
|
||||||
|
measure: ProgressionMeasureOptionData(
|
||||||
|
measure: measure,
|
||||||
|
label: 'Score',
|
||||||
|
lowerIsBetter: false,
|
||||||
|
),
|
||||||
|
points: const [],
|
||||||
|
hasAnyAllTimeData: false,
|
||||||
|
hasAnyCompletedExerciseResult: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class _FakeExercisePerformanceReferenceRepository
|
final class _FakeExercisePerformanceReferenceRepository
|
||||||
implements ExercisePerformanceReferenceRepository {
|
implements ExercisePerformanceReferenceRepository {
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user