diff --git a/examples/workout/README.md b/examples/workout/README.md index 76150f2..c766003 100644 --- a/examples/workout/README.md +++ b/examples/workout/README.md @@ -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 diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index 5f21d2c..4971e66 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -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) - }) } +pub fn correct_last_set_form( + app: AppState, + Form(input): Form, +) -> impl IntoEffect { + app.update(|state| match input.kg.trim().parse::() { + 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) -> 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")); diff --git a/examples/workout/templates/workout.css b/examples/workout/templates/workout.css index f13135f..3149945 100644 --- a/examples/workout/templates/workout.css +++ b/examples/workout/templates/workout.css @@ -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; diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml index 597cd90..a4d640e 100644 --- a/examples/workout/templates/workout.heml +++ b/examples/workout/templates/workout.heml @@ -16,9 +16,16 @@
- +
+
+ Fix last set +
+ + +
+
diff --git a/examples/workout/tests/e2e.rs b/examples/workout/tests/e2e.rs index abf762a..d644b10 100644 --- a/examples/workout/tests/e2e.rs +++ b/examples/workout/tests/e2e.rs @@ -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