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
+3 -3
View File
@@ -2,9 +2,9 @@
This is the phone-first local-first product exemplar for hemx. It shows a This is the phone-first local-first product exemplar for hemx. It shows a
complete useful session: next action shown, set completed, rest/next state complete useful session: next action shown, set completed, rest/next state
entered, progress updated, mistakes undone, workout finished, final export shared entered, progress updated, a mistyped set corrected and undone, workout finished,
or replayed, and invalid input, replay failure, or host denial recovered without final export shared or replayed, and invalid input, replay failure, or host denial
storing DOM patches or UI update payloads as truth. recovered without storing DOM patches or UI update payloads as truth.
req: examples/001 req: local/001 req: local/003 req: local/004 req: examples/001 req: local/001 req: local/003 req: local/004
## Run ## Run
+110 -2
View File
@@ -26,6 +26,7 @@ pub enum WorkoutCommand {
StartNextSet, StartNextSet,
FinishWorkout, FinishWorkout,
ChangeWeight { kg: f32 }, ChangeWeight { kg: f32 },
CorrectLastSet { kg: f32 },
SkipExercise, SkipExercise,
RecordNote { text: String }, RecordNote { text: String },
UndoLastAction, UndoLastAction,
@@ -47,6 +48,11 @@ pub enum WorkoutEvent {
exercise: String, exercise: String,
kg: f32, kg: f32,
}, },
SetEdited {
exercise: String,
set: u8,
kg: f32,
},
ExerciseSkipped { ExerciseSkipped {
exercise: String, exercise: String,
}, },
@@ -177,6 +183,13 @@ impl WorkoutState {
kg: *kg, 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 => { WorkoutCommand::SkipExercise if self.projection.phase != WorkoutPhase::Finished => {
let exercise = self.current_exercise()?; let exercise = self.current_exercise()?;
Some(WorkoutEvent::ExerciseSkipped { Some(WorkoutEvent::ExerciseSkipped {
@@ -220,7 +233,8 @@ impl WorkoutState {
} }
self.projection.phase = WorkoutPhase::Ready; 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) { if let Some(index) = self.exercise_index(exercise) {
self.plan[index].kg = *kg; self.plan[index].kg = *kg;
} }
@@ -343,6 +357,13 @@ impl WorkoutState {
self.plan.iter().position(|exercise| exercise.name == name) 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)> { fn next_ready_target(&self) -> Option<(&'static str, u8)> {
let exercise = self.current_exercise()?; let exercise = self.current_exercise()?;
if self.projection.completed_sets_for_current >= exercise.target_sets { if self.projection.completed_sets_for_current >= exercise.target_sets {
@@ -423,7 +444,7 @@ impl WorkoutState {
Some(last) if last.starts_with("started ") => { Some(last) if last.starts_with("started ") => {
format!("Last action: {last}. If that was a double tap, Undo returns to rest.") 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.") format!("Last action: {last}. Undo restores the previous plan.")
} }
Some(last) if last.starts_with("skipped ") => { Some(last) if last.starts_with("skipped ") => {
@@ -502,6 +523,9 @@ impl WorkoutEvent {
} }
Self::RestFinished { exercise, set } => format!("started {exercise} set {set}"), Self::RestFinished { exercise, set } => format!("started {exercise} set {set}"),
Self::WeightChanged { exercise, kg } => format!("changed {exercise} to {kg} kg"), 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::ExerciseSkipped { exercise } => format!("skipped {exercise}"),
Self::NoteRecorded { text } => format!("note: {text}"), Self::NoteRecorded { text } => format!("note: {text}"),
Self::WorkoutFinished { Self::WorkoutFinished {
@@ -529,6 +553,9 @@ impl WorkoutEvent {
Self::WeightChanged { exercise, kg } => { Self::WeightChanged { exercise, kg } => {
format!("weight_changed\t{}\t{kg}", export_field(exercise)) 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 } => { Self::ExerciseSkipped { exercise } => {
format!("exercise_skipped\t{}", export_field(exercise)) format!("exercise_skipped\t{}", export_field(exercise))
} }
@@ -558,6 +585,11 @@ impl WorkoutEvent {
exercise: (*exercise).into(), exercise: (*exercise).into(),
kg: kg.parse().ok()?, 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_skipped", exercise] => Some(Self::ExerciseSkipped {
exercise: (*exercise).into(), exercise: (*exercise).into(),
}), }),
@@ -660,6 +692,12 @@ pub struct ChangeWeightInput {
kg: String, kg: String,
} }
#[derive(Clone, Debug)]
#[hemx::form("correct_last_set")]
pub struct CorrectLastSetInput {
kg: String,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
#[hemx::form("record_note")] #[hemx::form("record_note")]
pub struct RecordNoteInput { pub struct RecordNoteInput {
@@ -671,6 +709,7 @@ pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry {
hemx_axum::state_interactions(ui::BUILD_FINGERPRINT, state) hemx_axum::state_interactions(ui::BUILD_FINGERPRINT, state)
.on_state(ui::workout::complete_set, complete_set) .on_state(ui::workout::complete_set, complete_set)
.on(ui::workout::change_weight, change_weight_form) .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_state(ui::workout::skip_exercise, skip_exercise)
.on(ui::workout::record_note, record_note_form) .on(ui::workout::record_note, record_note_form)
.on_state(ui::workout::undo_last_action, undo_last_action) .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 { pub fn skip_exercise(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
let event = state.accept(WorkoutCommand::SkipExercise); 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 { pub fn record_note_form(app: AppState, Form(input): Form<RecordNoteInput>) -> impl IntoEffect {
record_note(app, input.text) 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] #[test]
fn undo_recovers_last_session_action() { fn undo_recovers_last_session_action() {
// req: examples/001 req: local/001 req: local/004 // 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("Finish unlocks as you train"));
assert!(html.contains("Export after first set")); assert!(html.contains("Export after first set"));
assert!(html.contains("Undo last action")); assert!(html.contains("Undo last action"));
assert!(html.contains("Correct last set"));
assert!(html.contains("Host proof panel")); assert!(html.contains("Host proof panel"));
assert!(html.contains("Simulate share denied")); assert!(html.contains("Simulate share denied"));
assert!(html.contains("Simulate replay failure")); assert!(html.contains("Simulate replay failure"));
+8
View File
@@ -174,6 +174,11 @@
gap: 0.55rem; gap: 0.55rem;
align-items: end; align-items: end;
} }
.recovery-tools {
border-top: 1px dashed var(--line);
border-bottom: 1px dashed var(--line);
padding-block: 0.15rem;
}
.micro-form label, .micro-form label,
.voice-strip label { .voice-strip label {
display: grid; display: grid;
@@ -210,6 +215,9 @@
border-top: 1px dashed var(--line); border-top: 1px dashed var(--line);
padding-block-start: 0.35rem; padding-block-start: 0.35rem;
} }
.recovery-tools .micro-form {
margin-block-end: 0.55rem;
}
.host-proof p { .host-proof p {
margin-block-end: 0.7rem; margin-block-end: 0.7rem;
font-size: 0.92rem; font-size: 0.92rem;
+8 -1
View File
@@ -16,9 +16,16 @@
<button class="quiet-action" type="button" data-hemx-handle="skip_exercise">Skip exercise</button> <button class="quiet-action" type="button" data-hemx-handle="skip_exercise">Skip exercise</button>
</div> </div>
<form class="micro-form" data-hemx-handle="change_weight" method="post"> <form class="micro-form" data-hemx-handle="change_weight" method="post">
<label>Adjust weight <input name="kg" value="52.5" inputmode="decimal" aria-label="Weight in kilograms"></label> <label>Adjust next weight <input name="kg" value="52.5" inputmode="decimal" aria-label="Weight in kilograms"></label>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
<details class="recovery-tools">
<summary>Fix last set</summary>
<form class="micro-form" data-hemx-handle="correct_last_set" method="post">
<label>Correct last set <input name="kg" value="24" inputmode="decimal" aria-label="Corrected last set weight in kilograms"></label>
<button type="submit">Correct</button>
</form>
</details>
<form class="voice-strip" data-hemx-handle="record_note" method="post"> <form class="voice-strip" data-hemx-handle="record_note" method="post">
<label>Session note <input name="text" value="left shoulder felt tight"></label> <label>Session note <input name="text" value="left shoulder felt tight"></label>
<button type="submit">Record</button> <button type="submit">Record</button>
+1
View File
@@ -54,6 +54,7 @@ fn workout_is_e2e_working_over_http() {
assert!(home.text().contains("Now-first Workout Copilot")); assert!(home.text().contains("Now-first Workout Copilot"));
assert!(home.text().contains("Finish & recover")); assert!(home.text().contains("Finish & recover"));
assert!(home.text().contains("Undo last action")); assert!(home.text().contains("Undo last action"));
assert!(home.text().contains("Fix last set"));
assert!(home.text().contains("Private event log")); assert!(home.text().contains("Private event log"));
assert!(home.text().contains("Replay export")); assert!(home.text().contains("Replay export"));
assert!(home assert!(home