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