feat(exécution): dernière performance et meilleur score par exercice (ticket #81)

Ajoute la migration Drift v17 (colonne sourceExerciseIdSnapshot sur
workout_history_set_results et workout_history_step_results, index
associés), le port ExercisePerformanceReferenceRepository et son
implémentation Drift, et le use case
ExercisePerformanceReferenceUseCase (dernière performance + meilleur
score par métrique active pour un exercice donné).

Validé GO par Main : dart analyze propre (1 lint mineur de style),
178 tests (4 échecs préexistants sans rapport avec ce lot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:54:49 +02:00
parent e05405157a
commit ac51c16b51
10 changed files with 1073 additions and 3 deletions

View File

@ -1602,6 +1602,138 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
}
}
final class DriftExercisePerformanceReferenceRepository
implements ExercisePerformanceReferenceRepository {
const DriftExercisePerformanceReferenceRepository(this.database);
final db.AppDatabase database;
@override
Future<bool> hasAnyCompletedHistoryForExercise(String exerciseId) async {
final row = await database
.customSelect(
'SELECT 1 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 history.deleted_at IS NULL '
'AND history.completed = 1 '
'AND result.status = ? '
'AND result.source_exercise_id_snapshot = ? '
'LIMIT 1',
variables: [
const Variable<String>('completed'),
Variable<String>(exerciseId),
],
)
.getSingleOrNull();
return row != null;
}
@override
Future<WorkoutHistorySetPerformance?> findLatestSetPerformance({
required String exerciseId,
required ActivePerformanceMeasures activeMeasures,
required int currentSetIndex,
}) async {
final valuePredicate = _activeMeasuresPredicate(activeMeasures);
if (valuePredicate == null) {
return null;
}
final row = await database
.customSelect(
'''
WITH candidates AS (
SELECT
result.workout_history_id,
history.started_at AS history_started_at,
result.set_index,
result.exercise_name_snapshot,
result.score_input_mode_snapshot,
result.actual_time_ms,
result.actual_reps,
result.actual_score,
result.actual_score_time_ms,
result.completed_at
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 history.deleted_at IS NULL
AND history.completed = 1
AND result.status = ?
AND result.source_exercise_id_snapshot = ?
AND ($valuePredicate)
),
latest_history AS (
SELECT workout_history_id
FROM candidates
ORDER BY history_started_at DESC
LIMIT 1
)
SELECT *
FROM candidates
WHERE workout_history_id = (SELECT workout_history_id FROM latest_history)
ORDER BY
CASE WHEN set_index = ? THEN 0 ELSE 1 END,
set_index DESC
LIMIT 1
''',
variables: [
const Variable<String>('completed'),
Variable<String>(exerciseId),
Variable<int>(currentSetIndex),
],
)
.getSingleOrNull();
return row == null ? null : _setPerformanceFromRow(row);
}
@override
Future<WorkoutHistoryMetricPerformance?> findBestMetricPerformance({
required String exerciseId,
required PerformanceMetric metric,
required domain.ScoreInputMode scoreInputMode,
}) async {
final metricSql = _metricSql(metric, scoreInputMode);
final row = await database
.customSelect(
'''
SELECT
result.workout_history_id,
history.started_at AS history_started_at,
result.set_index,
result.exercise_name_snapshot,
result.score_input_mode_snapshot,
result.actual_time_ms,
result.actual_reps,
result.actual_score,
result.actual_score_time_ms,
result.completed_at
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 history.deleted_at IS NULL
AND history.completed = 1
AND result.status = ?
AND result.source_exercise_id_snapshot = ?
AND ${metricSql.predicate}
ORDER BY ${metricSql.ordering},
history.started_at DESC,
result.completed_at DESC
LIMIT 1
''',
variables: [
const Variable<String>('completed'),
Variable<String>(exerciseId),
],
)
.getSingleOrNull();
return row == null ? null : _metricPerformanceFromRow(row, metric: metric);
}
}
Future<void> _upsertWithChangeLog({
required db.AppDatabase database,
required String tableName,
@ -2863,6 +2995,94 @@ domain.ActiveRestState _activeRestStateFromCustomRow(QueryRow row) {
);
}
WorkoutHistorySetPerformance _setPerformanceFromRow(QueryRow row) {
final data = row.data;
return WorkoutHistorySetPerformance(
workoutHistoryId: data['workout_history_id'] as String,
startedAt: _dateTimeFromData(data, 'history_started_at'),
setIndex: data['set_index'] as int,
exerciseNameSnapshot: data['exercise_name_snapshot'] as String,
scoreInputModeSnapshot: _scoreInputModeFromDb(
data['score_input_mode_snapshot'] as String,
),
actualTimeMs: data['actual_time_ms'] as int?,
actualReps: data['actual_reps'] as int?,
actualScore: (data['actual_score'] as num?)?.toDouble(),
actualScoreTimeMs: data['actual_score_time_ms'] as int?,
completedAt: _dateTimeOrNullFromData(data, 'completed_at'),
);
}
WorkoutHistoryMetricPerformance _metricPerformanceFromRow(
QueryRow row, {
required PerformanceMetric metric,
}) {
final set = _setPerformanceFromRow(row);
return WorkoutHistoryMetricPerformance(
workoutHistoryId: set.workoutHistoryId,
startedAt: set.startedAt,
setIndex: set.setIndex,
exerciseNameSnapshot: set.exerciseNameSnapshot,
metric: metric,
scoreInputModeSnapshot: set.scoreInputModeSnapshot,
actualTimeMs: set.actualTimeMs,
actualReps: set.actualReps,
actualScore: set.actualScore,
actualScoreTimeMs: set.actualScoreTimeMs,
completedAt: set.completedAt,
);
}
String? _activeMeasuresPredicate(ActivePerformanceMeasures measures) {
final predicates = <String>[];
if (measures.scoreEnabled) {
predicates.add(
measures.scoreInputMode == domain.ScoreInputMode.stopwatch
? "(result.score_input_mode_snapshot = 'stopwatch' "
'AND result.actual_score_time_ms IS NOT NULL)'
: "(result.score_input_mode_snapshot = 'manual' "
'AND result.actual_score IS NOT NULL)',
);
}
if (measures.repsEnabled) {
predicates.add('result.actual_reps IS NOT NULL');
}
if (measures.timeEnabled) {
predicates.add('result.actual_time_ms IS NOT NULL');
}
return predicates.isEmpty ? null : predicates.join(' OR ');
}
({String predicate, String ordering}) _metricSql(
PerformanceMetric metric,
domain.ScoreInputMode scoreInputMode,
) {
return switch (metric) {
PerformanceMetric.score =>
scoreInputMode == domain.ScoreInputMode.stopwatch
? (
predicate:
"result.score_input_mode_snapshot = 'stopwatch' "
'AND result.actual_score_time_ms IS NOT NULL',
ordering: 'result.actual_score_time_ms ASC',
)
: (
predicate:
"result.score_input_mode_snapshot = 'manual' "
'AND result.actual_score IS NOT NULL',
ordering: 'result.actual_score DESC',
),
PerformanceMetric.reps => (
predicate: 'result.actual_reps IS NOT NULL',
ordering: 'result.actual_reps DESC',
),
PerformanceMetric.time => (
predicate: 'result.actual_time_ms IS NOT NULL',
ordering: 'result.actual_time_ms DESC',
),
};
}
db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion(
domain.ActiveScoreStopwatchState state,
) {
@ -3108,6 +3328,7 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
actualScoreTimeMs: Value(result.actualScoreTimeMs),
scoreLabelSnapshot: Value(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value(result.scoreUnitSnapshot),
sourceExerciseIdSnapshot: Value(result.sourceExerciseIdSnapshot),
startedAt: Value(_utcOrNull(result.startedAt)),
completedAt: Value(_utcOrNull(result.completedAt)),
status: Value(_setResultStatusToDb(result.status)),
@ -3160,6 +3381,7 @@ db.WorkoutHistoryStepResultsCompanion _workoutHistoryStepResultCompanion(
actualScore: Value<double?>(result.actualScore),
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
note: Value<String?>(result.note),
sourceExerciseIdSnapshot: Value<String?>(result.sourceExerciseIdSnapshot),
);
}
@ -3210,6 +3432,7 @@ domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow(
actualScoreTimeMs: row.actualScoreTimeMs,
scoreLabelSnapshot: row.scoreLabelSnapshot,
scoreUnitSnapshot: row.scoreUnitSnapshot,
sourceExerciseIdSnapshot: row.sourceExerciseIdSnapshot,
startedAt: _utcOrNull(row.startedAt),
completedAt: _utcOrNull(row.completedAt),
status: _setResultStatusFromDb(row.status),
@ -3249,6 +3472,7 @@ domain.WorkoutHistoryStepResult _workoutHistoryStepResultFromRow(
actualScore: row.actualScore,
actualScoreTimeMs: row.actualScoreTimeMs,
note: row.note,
sourceExerciseIdSnapshot: row.sourceExerciseIdSnapshot,
);
}