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