feat(workout): recover common session failures

Make the workout exemplar recover cleanly from invalid edits, accidental double-advance, replay parse failures, and denied or timed-out host export results while keeping local events as truth.

req: examples/001

req: local/001

req: local/003

req: local/004

req: host/002

req: host/005
This commit is contained in:
slhx agent
2026-06-11 23:25:21 +02:00
parent a514237b09
commit c146e9b932
6 changed files with 176 additions and 22 deletions
+164 -14
View File
@@ -388,12 +388,23 @@ impl WorkoutState {
.join("\n")
}
fn replay_export(&mut self, export: &str) -> usize {
fn replay_export(&mut self, export: &str) -> Result<usize, String> {
// req: local/001 req: local/003 req: local/004
let events = export
let lines = export
.lines()
.filter_map(WorkoutEvent::from_export_line)
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>();
if lines.is_empty() {
return Err("nothing to replay yet; complete a set first".into());
}
let mut events = Vec::with_capacity(lines.len());
for (index, line) in lines.iter().enumerate() {
let event = WorkoutEvent::from_export_line(line)
.ok_or_else(|| format!("line {} is not a workout event", index + 1))?;
events.push(event);
}
let mut replayed = WorkoutState::demo();
for event in &events {
replayed.events.push(event.clone());
@@ -404,7 +415,26 @@ impl WorkoutState {
events.len()
);
*self = replayed;
events.len()
Ok(events.len())
}
fn recovery_text(&self) -> String {
match self.activity.front() {
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 ") => {
format!("Last action: {last}. Undo restores the previous plan.")
}
Some(last) if last.starts_with("skipped ") => {
format!("Last action: {last}. Undo restores the skipped exercise.")
}
Some(last) if last.starts_with("undid ") => {
format!("Recovery complete: {last}. Continue from the restored next action.")
}
Some(last) => format!("Last action: {last}. Undo is available."),
None => "Undo appears here after your first session event.".into(),
}
}
}
@@ -575,11 +605,7 @@ pub fn view(state: &WorkoutState) -> Workout {
primary_action: state.projection.primary_action.clone(),
status: state.projection.progress.clone(),
progress: state.projection.progress.clone(),
recovery_status: state
.activity
.front()
.map(|last| format!("Last action: {last}. Undo is available."))
.unwrap_or_else(|| "Undo appears here after your first session event.".into()),
recovery_status: state.recovery_text(),
event_log: state.event_log_text(),
export_payload: if export.is_empty() {
"Complete a set to create a replayable export.".into()
@@ -597,7 +623,7 @@ pub fn render(state: &WorkoutState) -> Html {
#[derive(Clone, Debug)]
#[hemx::form("change_weight")]
pub struct ChangeWeightInput {
kg: f32,
kg: String,
}
#[derive(Clone, Debug)]
@@ -617,6 +643,9 @@ pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry {
.on_state(ui::workout::replay_export, replay_export)
.on_state(ui::workout::export_log, export_log)
.on_state(ui::workout::record_share_result, record_share_result)
.on_state(ui::workout::record_share_denied, record_share_denied)
.on_state(ui::workout::record_host_timeout, record_host_timeout)
.on_state(ui::workout::replay_broken_export, replay_broken_export)
.on_state(ui::workout::request_native_haptic, request_native_haptic)
.on_state(
ui::workout::record_native_haptic_ack,
@@ -698,7 +727,22 @@ pub fn undo_last_action(app: AppState) -> impl IntoEffect {
}
pub fn change_weight_form(app: AppState, Form(input): Form<ChangeWeightInput>) -> impl IntoEffect {
change_weight(app, input.kg)
app.update(|state| match input.kg.trim().parse::<f32>() {
Ok(kg) if kg.is_finite() && kg >= 0.0 => {
let event = state.accept(WorkoutCommand::ChangeWeight { kg });
effects(
state,
event.map_or_else(
|| "Enter a valid weight for the current or next exercise.".into(),
|event| event.summary(),
),
)
}
_ => effects(
state,
String::from("Enter a valid non-negative weight; your session is unchanged."),
),
})
}
pub fn record_note_form(app: AppState, Form(input): Form<RecordNoteInput>) -> impl IntoEffect {
@@ -722,8 +766,26 @@ pub fn replay_export(app: AppState) -> impl IntoEffect {
app.update(|state| {
// req: local/001 req: local/003 req: local/004
let export = state.export_event_log();
let count = state.replay_export(&export);
effects(state, format!("Replayed {count} exported workout events"))
match state.replay_export(&export) {
Ok(count) => effects(state, format!("Replayed {count} exported workout events")),
Err(error) => effects(
state,
format!("Replay failed: {error}. Current session is unchanged."),
),
}
})
}
pub fn replay_broken_export(app: AppState) -> impl IntoEffect {
app.update(|state| {
// req: local/001 req: local/003 req: local/004
match state.replay_export("not-a-workout-export") {
Ok(count) => effects(state, format!("Replayed {count} exported workout events")),
Err(error) => effects(
state,
format!("Replay failed: {error}. Current session is unchanged."),
),
}
})
}
@@ -764,6 +826,33 @@ pub fn record_share_result(app: AppState) -> impl IntoEffect {
})
}
pub fn record_share_denied(app: AppState) -> impl IntoEffect {
app.update(|state| {
// req: host/002 req: host/005 req: local/003
apply_host_event(
state,
HostEvent::PermissionDenied {
capability: Capability::Share,
},
);
effects(state, "Share permission denial returned through app code")
})
}
pub fn record_host_timeout(app: AppState) -> impl IntoEffect {
app.update(|state| {
// req: host/002 req: host/005 req: local/003
apply_host_event(
state,
HostEvent::Failed {
id: Some(HostCallId::new("workout-export")),
message: "share timed out".into(),
},
);
effects(state, "Host timeout returned through app code")
})
}
pub fn request_native_haptic(app: AppState) -> impl IntoEffect {
app.update(|state| {
// req: host/001 req: host/004
@@ -810,7 +899,18 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
HostEvent::ShareCompleted {
completed: false, ..
} => {
state.host_status = "Share cancelled; local event log unchanged.".into();
state.host_status =
"Share cancelled; local event log unchanged. Try Share export when ready.".into();
}
HostEvent::PermissionDenied { capability } if capability == Capability::Share => {
state.host_status =
"Share permission denied. Local export text is still ready; try again or copy it."
.into();
}
HostEvent::Failed { message, .. } => {
state.host_status = format!(
"Host unavailable: {message}. Keep the local export below and try Share export again."
);
}
HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => {
state.host_status =
@@ -912,6 +1012,31 @@ mod tests {
);
}
#[test]
fn invalid_weight_and_replay_failure_keep_session_recoverable() {
// req: examples/001 req: local/001 req: local/003 req: local/004
let app = AppState::demo();
run(|()| complete_set(app.clone()), ());
let invalid = run(
|input| change_weight_form(app.clone(), Form(input)),
ChangeWeightInput { kg: "oops".into() },
);
assert!(contains_payload_text(&invalid, "valid non-negative weight"));
assert_eq!(
app.with_workout(|state| (state.events.len(), state.projection.phase.clone())),
(1, WorkoutPhase::Resting)
);
let replay = run(|()| replay_broken_export(app.clone()), ());
assert!(contains_payload_text(&replay, "Replay failed: line 1"));
assert!(contains_payload_text(
&replay,
"Current session is unchanged"
));
assert_eq!(app.with_workout(|state| state.events.len()), 1);
}
#[test]
fn export_payload_replays_into_projection_before_ui_effects() {
// req: local/001 req: local/003 req: local/004
@@ -964,6 +1089,29 @@ mod tests {
));
}
#[test]
fn host_failure_results_keep_local_export_recoverable() {
// req: host/002 req: host/005 req: local/003
let app = AppState::demo();
run(|()| complete_set(app.clone()), ());
let denied = run(|()| record_share_denied(app.clone()), ());
assert!(contains_payload_text(&denied, "Share permission denial"));
assert!(contains_payload_text(
&denied,
"Local export text is still ready"
));
assert!(!app.with_workout(|state| state.projection.exported));
let timeout = run(|()| record_host_timeout(app.clone()), ());
assert!(contains_payload_text(&timeout, "Host timeout"));
assert!(contains_payload_text(
&timeout,
"Keep the local export below"
));
assert_eq!(app.with_workout(|state| state.events.len()), 1);
}
#[test]
fn native_shell_haptic_result_returns_through_app_code() {
// req: host/001 req: host/002 req: host/005
@@ -999,6 +1147,8 @@ mod tests {
assert!(html.contains("Undo last action"));
assert!(html.contains("Share export"));
assert!(html.contains("Host proof panel"));
assert!(html.contains("Simulate share denied"));
assert!(html.contains("Simulate replay failure"));
assert!(!html.contains(&format!("<{}", "script")));
assert!(!html.contains("querySelector"));
}