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
@@ -49,7 +49,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Prefer links or pointers to canonical sources over copied lists.
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004
- Run the workout product exemplar with `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction lives in `examples/workout/DESIGN.md`. req: examples/001
- Run the workout product exemplar with `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/001
- hemx core stays small: effects, typed ids, registries, and wire schema only.
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
- Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes.
+1 -1
View File
@@ -795,7 +795,7 @@ what a valid business email is.
## examples
### req: examples/001
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus a complete session flow with rest/next state, progress, undo recovery, finish/export, and host boundary results returned through app code, and must be runnable with the documented cargo command; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core.
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus a complete session flow with rest/next state, progress, undo recovery, finish/export, recoverable invalid input and host/replay failures, and host boundary results returned through app code, and must be runnable with the documented cargo command; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core.
### req: examples/002
002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions.
+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>