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