feat(workout): correct last set from local events

Add a recovery path for mistyped completed-set weight: append a typed correction event, replay it through the local projection, and make undo restore the previous session state.

req: examples/001

req: local/001

req: local/003

req: local/004
This commit is contained in:
slhx agent
2026-06-11 23:34:17 +02:00
parent 900ae9f51f
commit 1289106ed4
5 changed files with 130 additions and 6 deletions
+110 -2
View File
@@ -26,6 +26,7 @@ pub enum WorkoutCommand {
StartNextSet,
FinishWorkout,
ChangeWeight { kg: f32 },
CorrectLastSet { kg: f32 },
SkipExercise,
RecordNote { text: String },
UndoLastAction,
@@ -47,6 +48,11 @@ pub enum WorkoutEvent {
exercise: String,
kg: f32,
},
SetEdited {
exercise: String,
set: u8,
kg: f32,
},
ExerciseSkipped {
exercise: String,
},
@@ -177,6 +183,13 @@ impl WorkoutState {
kg: *kg,
})
}
WorkoutCommand::CorrectLastSet { kg } if kg.is_finite() && *kg >= 0.0 => self
.last_completed_set()
.map(|(exercise, set)| WorkoutEvent::SetEdited {
exercise,
set,
kg: *kg,
}),
WorkoutCommand::SkipExercise if self.projection.phase != WorkoutPhase::Finished => {
let exercise = self.current_exercise()?;
Some(WorkoutEvent::ExerciseSkipped {
@@ -220,7 +233,8 @@ impl WorkoutState {
}
self.projection.phase = WorkoutPhase::Ready;
}
WorkoutEvent::WeightChanged { exercise, kg } => {
WorkoutEvent::WeightChanged { exercise, kg }
| WorkoutEvent::SetEdited { exercise, kg, .. } => {
if let Some(index) = self.exercise_index(exercise) {
self.plan[index].kg = *kg;
}
@@ -343,6 +357,13 @@ impl WorkoutState {
self.plan.iter().position(|exercise| exercise.name == name)
}
fn last_completed_set(&self) -> Option<(String, u8)> {
self.events.iter().rev().find_map(|event| match event {
WorkoutEvent::SetCompleted { exercise, set, .. } => Some((exercise.clone(), *set)),
_ => None,
})
}
fn next_ready_target(&self) -> Option<(&'static str, u8)> {
let exercise = self.current_exercise()?;
if self.projection.completed_sets_for_current >= exercise.target_sets {
@@ -423,7 +444,7 @@ impl WorkoutState {
Some(last) if last.starts_with("started ") => {
format!("Last action: {last}. If that was a double tap, Undo returns to rest.")
}
Some(last) if last.starts_with("changed ") => {
Some(last) if last.starts_with("changed ") || last.starts_with("corrected ") => {
format!("Last action: {last}. Undo restores the previous plan.")
}
Some(last) if last.starts_with("skipped ") => {
@@ -502,6 +523,9 @@ impl WorkoutEvent {
}
Self::RestFinished { exercise, set } => format!("started {exercise} set {set}"),
Self::WeightChanged { exercise, kg } => format!("changed {exercise} to {kg} kg"),
Self::SetEdited { exercise, set, kg } => {
format!("corrected {exercise} set {set} to {kg} kg")
}
Self::ExerciseSkipped { exercise } => format!("skipped {exercise}"),
Self::NoteRecorded { text } => format!("note: {text}"),
Self::WorkoutFinished {
@@ -529,6 +553,9 @@ impl WorkoutEvent {
Self::WeightChanged { exercise, kg } => {
format!("weight_changed\t{}\t{kg}", export_field(exercise))
}
Self::SetEdited { exercise, set, kg } => {
format!("set_edited\t{}\t{set}\t{kg}", export_field(exercise))
}
Self::ExerciseSkipped { exercise } => {
format!("exercise_skipped\t{}", export_field(exercise))
}
@@ -558,6 +585,11 @@ impl WorkoutEvent {
exercise: (*exercise).into(),
kg: kg.parse().ok()?,
}),
["set_edited", exercise, set, kg] => Some(Self::SetEdited {
exercise: (*exercise).into(),
set: set.parse().ok()?,
kg: kg.parse().ok()?,
}),
["exercise_skipped", exercise] => Some(Self::ExerciseSkipped {
exercise: (*exercise).into(),
}),
@@ -660,6 +692,12 @@ pub struct ChangeWeightInput {
kg: String,
}
#[derive(Clone, Debug)]
#[hemx::form("correct_last_set")]
pub struct CorrectLastSetInput {
kg: String,
}
#[derive(Clone, Debug)]
#[hemx::form("record_note")]
pub struct RecordNoteInput {
@@ -671,6 +709,7 @@ pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry {
hemx_axum::state_interactions(ui::BUILD_FINGERPRINT, state)
.on_state(ui::workout::complete_set, complete_set)
.on(ui::workout::change_weight, change_weight_form)
.on(ui::workout::correct_last_set, correct_last_set_form)
.on_state(ui::workout::skip_exercise, skip_exercise)
.on(ui::workout::record_note, record_note_form)
.on_state(ui::workout::undo_last_action, undo_last_action)
@@ -737,6 +776,19 @@ pub fn change_weight(app: AppState, kg: f32) -> impl IntoEffect {
})
}
pub fn correct_last_set(app: AppState, kg: f32) -> impl IntoEffect {
app.update(|state| {
let event = state.accept(WorkoutCommand::CorrectLastSet { kg });
effects(
state,
event.map_or_else(
|| "Complete a set before correcting its recorded weight.".into(),
|event| event.summary(),
),
)
})
}
pub fn skip_exercise(app: AppState) -> impl IntoEffect {
app.update(|state| {
let event = state.accept(WorkoutCommand::SkipExercise);
@@ -782,6 +834,28 @@ pub fn change_weight_form(app: AppState, Form(input): Form<ChangeWeightInput>) -
})
}
pub fn correct_last_set_form(
app: AppState,
Form(input): Form<CorrectLastSetInput>,
) -> impl IntoEffect {
app.update(|state| match input.kg.trim().parse::<f32>() {
Ok(kg) if kg.is_finite() && kg >= 0.0 => {
let event = state.accept(WorkoutCommand::CorrectLastSet { kg });
effects(
state,
event.map_or_else(
|| "Complete a set before correcting its recorded weight.".into(),
|event| event.summary(),
),
)
}
_ => effects(
state,
String::from("Enter a valid corrected weight; your session is unchanged."),
),
})
}
pub fn record_note_form(app: AppState, Form(input): Form<RecordNoteInput>) -> impl IntoEffect {
record_note(app, input.text)
}
@@ -1030,6 +1104,39 @@ mod tests {
);
}
#[test]
fn correct_last_set_is_replayable_and_undoable() {
// req: examples/001 req: local/001 req: local/003 req: local/004
let app = AppState::demo();
run(|()| complete_set(app.clone()), ());
let corrected = run(|()| correct_last_set(app.clone(), 26.0), ());
assert!(contains_payload_text(
&corrected,
"corrected Goblet squat set 1 to 26 kg"
));
assert_eq!(
app.with_workout(|state| (state.events.len(), state.plan[0].kg)),
(2, 26.0)
);
let export = app.with_workout(WorkoutState::export_event_log);
assert!(export.contains("set_edited\tGoblet squat\t1\t26"));
let replay = run(|()| replay_export(app.clone()), ());
assert!(contains_payload_text(
&replay,
"Replayed 2 exported workout events"
));
assert_eq!(app.with_workout(|state| state.plan[0].kg), 26.0);
let undone = run(|()| undo_last_action(app.clone()), ());
assert!(contains_payload_text(
&undone,
"Undid: corrected Goblet squat set 1 to 26 kg"
));
assert_eq!(app.with_workout(|state| state.plan[0].kg), 24.0);
}
#[test]
fn undo_recovers_last_session_action() {
// req: examples/001 req: local/001 req: local/004
@@ -1186,6 +1293,7 @@ mod tests {
assert!(html.contains("Finish unlocks as you train"));
assert!(html.contains("Export after first set"));
assert!(html.contains("Undo last action"));
assert!(html.contains("Correct last set"));
assert!(html.contains("Host proof panel"));
assert!(html.contains("Simulate share denied"));
assert!(html.contains("Simulate replay failure"));