diff --git a/AGENTS.md b/AGENTS.md index dfd2071..c009fd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 09e42ed..16266bd 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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. diff --git a/examples/workout/DESIGN.md b/examples/workout/DESIGN.md index 6adeccf..9ea157f 100644 --- a/examples/workout/DESIGN.md +++ b/examples/workout/DESIGN.md @@ -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 diff --git a/examples/workout/README.md b/examples/workout/README.md index bbe27ae..94e7e9e 100644 --- a/examples/workout/README.md +++ b/examples/workout/README.md @@ -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 diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index 8e594e1..ba34122 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -388,12 +388,23 @@ impl WorkoutState { .join("\n") } - fn replay_export(&mut self, export: &str) -> usize { + fn replay_export(&mut self, export: &str) -> Result { // 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::>(); + 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) -> impl IntoEffect { - change_weight(app, input.kg) + app.update(|state| match input.kg.trim().parse::() { + 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) -> 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")); } diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml index e85758b..9944015 100644 --- a/examples/workout/templates/workout.heml +++ b/examples/workout/templates/workout.heml @@ -37,6 +37,9 @@

Development-only host results return through app code before UI effects.

+ + +