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:
@ -48,7 +48,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 17;
|
||||
int get schemaVersion => 18;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -228,10 +228,21 @@ final class AppDatabase extends _$AppDatabase {
|
||||
'CREATE INDEX IF NOT EXISTS idx_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(
|
||||
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_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(
|
||||
'CREATE INDEX IF NOT EXISTS '
|
||||
'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 '
|
||||
'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(
|
||||
'CREATE INDEX IF NOT EXISTS '
|
||||
'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({
|
||||
required db.AppDatabase database,
|
||||
required String tableName,
|
||||
|
||||
Reference in New Issue
Block a user