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
complete useful session: next action shown, set completed, rest/next state
entered, progress updated, mistakes undone, workout finished, final export shared
or replayed, and invalid input, replay failure, or host denial recovered without
storing DOM patches or UI update payloads as truth.
entered, progress updated, a mistyped set corrected and undone, workout finished,
final export shared or replayed, and invalid input, replay failure, or host denial
recovered without storing DOM patches or UI update payloads as truth.
req: examples/001 req: local/001 req: local/003 req: local/004
## Run
+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"));
+8
View File
@@ -174,6 +174,11 @@
gap: 0.55rem;
align-items: end;
}
.recovery-tools {
border-top: 1px dashed var(--line);
border-bottom: 1px dashed var(--line);
padding-block: 0.15rem;
}
.micro-form label,
.voice-strip label {
display: grid;
@@ -210,6 +215,9 @@
border-top: 1px dashed var(--line);
padding-block-start: 0.35rem;
}
.recovery-tools .micro-form {
margin-block-end: 0.55rem;
}
.host-proof p {
margin-block-end: 0.7rem;
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>
</div>
<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>
</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">
<label>Session note <input name="text" value="left shoulder felt tight"></label>
<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("Finish & recover"));
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("Replay export"));
assert!(home