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
+1 -1
View File
@@ -5,7 +5,7 @@ Direction: gym-floor command slate — one heavy next action, thumb-safe command
Preserve:
- First viewport shows one obvious action path before logs or host details.
- Controls stay large enough for sweaty one-handed use; forms remain secondary to tap actions.
- Rest/next, progress, undo recovery, and finish/export feel like session states, not framework demos.
- Rest/next, progress, undo recovery, invalid input, replay failure, host denial, and finish/export feel like session states, not framework demos.
- Local truth is visible as a ledger/export, not hidden behind dashboard metrics. req: local/001 req: local/003
- Host capabilities read as finish/export product states first, with proof controls tucked behind an explicit panel instead of competing with the user flow. req: host/002
+6 -5
View File
@@ -2,8 +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, and the result
exported or replayed without storing DOM patches or UI update payloads as truth.
entered, progress updated, mistakes undone, workout finished, 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
@@ -32,8 +33,8 @@ cargo test -p hemx-workout-example
The E2E test starts the real HTTP binary, loads the page, checks the shared
runtime asset, submits workout interactions, decodes hemx effect responses, and
replays exported events back into a projection. Unit tests cover rest/next,
finish, undo recovery, export replay, and host-result boundaries. req: test/001
req: examples/001
finish, undo recovery, invalid input, replay failure, export replay, and
host-result boundaries. req: test/001 req: examples/001
For the full repository gate:
@@ -60,7 +61,7 @@ ship. req: axum_integration/005 req: examples/005
- Core workout logging works without network availability once the page/runtime
is loaded: commands/events/projections are app truth, including rest/next,
finish, and undo recovery. req: local/001
finish, invalid input, replay failure, and undo recovery. req: local/001
- Browser/PWA export uses the host capability contract and the host result
returns through app code before UI effects. req: host/001 req: host/005
- Native-shell-shaped haptic acknowledgment uses the same host call/event path
+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"));
}
+3
View File
@@ -37,6 +37,9 @@
<p>Development-only host results return through app code before UI effects.</p>
<div class="host-actions">
<button type="button" data-hemx-handle="record_share_result">Mark share completed</button>
<button type="button" data-hemx-handle="record_share_denied">Simulate share denied</button>
<button type="button" data-hemx-handle="record_host_timeout">Simulate host timeout</button>
<button type="button" data-hemx-handle="replay_broken_export">Simulate replay failure</button>
<button type="button" data-hemx-handle="request_native_haptic">Request native haptic</button>
<button type="button" data-hemx-handle="record_native_haptic_ack">Mark haptic acknowledged</button>
</div>