use hemplate::Hemplate; use hemx::{Html, IntoEffect}; use hemx_axum::Form; use hemx_host::{ browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest, CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, HostFailure, HostFailureKind, SharePayload as HostShareData, }; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; #[hemx::surface] pub mod ui {} #[derive(Clone, Debug, PartialEq)] pub struct ExercisePlan { pub name: &'static str, pub target_sets: u8, pub reps: u8, pub kg: f32, } #[derive(Clone, Debug, PartialEq)] pub enum WorkoutCommand { CompleteSet, StartNextSet, FinishWorkout, ChangeWeight { kg: f32 }, CorrectLastSet { kg: f32 }, SkipExercise, RecordNote { text: String }, UndoLastAction, } #[derive(Clone, Debug, PartialEq)] pub enum WorkoutEvent { SetCompleted { exercise: String, set: u8, reps: u8, kg: f32, }, RestFinished { exercise: String, set: u8, }, WeightChanged { exercise: String, kg: f32, }, SetEdited { exercise: String, set: u8, kg: f32, }, ExerciseSkipped { exercise: String, }, NoteRecorded { text: String, }, WorkoutFinished { completed_sets: u8, total_sets: u8, }, } #[derive(Clone, Debug, PartialEq)] pub enum WorkoutPhase { Ready, Resting, ReadyToFinish, Finished, } #[derive(Clone, Debug, PartialEq)] pub struct WorkoutProjection { pub current_exercise: usize, pub completed_sets_for_current: u8, pub total_completed_sets: u8, pub phase: WorkoutPhase, pub next_action: String, pub primary_action: String, pub progress: String, pub exported: bool, } #[derive(Clone, Debug)] pub struct WorkoutState { pub plan: Vec, pub commands: Vec, pub events: Vec, pub projection: WorkoutProjection, pub activity: VecDeque, pub host_status: String, } #[derive(Clone)] pub struct AppState { workout: Arc>, } impl AppState { pub fn demo() -> Self { Self { workout: Arc::new(Mutex::new(WorkoutState::demo())), } } pub fn with_workout(&self, f: impl FnOnce(&WorkoutState) -> R) -> R { let workout = self.workout.lock().unwrap(); f(&workout) } fn update(&self, f: impl FnOnce(&mut WorkoutState) -> R) -> R { let mut workout = self.workout.lock().unwrap(); f(&mut workout) } } impl WorkoutState { pub fn demo() -> Self { let mut state = Self { plan: demo_plan(), commands: Vec::new(), events: Vec::new(), projection: WorkoutProjection::start(), activity: VecDeque::new(), host_status: "Ready to keep the session private until you export.".into(), }; state.refresh_next_action(); state } fn primary_command(&self) -> WorkoutCommand { match self.projection.phase { WorkoutPhase::Ready => WorkoutCommand::CompleteSet, WorkoutPhase::Resting => WorkoutCommand::StartNextSet, WorkoutPhase::ReadyToFinish | WorkoutPhase::Finished => WorkoutCommand::FinishWorkout, } } fn accept(&mut self, command: WorkoutCommand) -> Option { // req: local/001 req: local/004 let event = self.validate(&command)?; self.commands.push(command); self.events.push(event.clone()); self.project(&event); Some(event) } fn validate(&self, command: &WorkoutCommand) -> Option { match command { WorkoutCommand::CompleteSet if self.projection.phase == WorkoutPhase::Ready => { let exercise = self.current_exercise()?; Some(WorkoutEvent::SetCompleted { exercise: exercise.name.into(), set: self.projection.completed_sets_for_current + 1, reps: exercise.reps, kg: exercise.kg, }) } WorkoutCommand::StartNextSet if self.projection.phase == WorkoutPhase::Resting => { let (exercise, set) = self.next_ready_target()?; Some(WorkoutEvent::RestFinished { exercise: exercise.into(), set, }) } WorkoutCommand::FinishWorkout if self.projection.phase == WorkoutPhase::ReadyToFinish => { Some(WorkoutEvent::WorkoutFinished { completed_sets: self.projection.total_completed_sets, total_sets: self.total_target_sets(), }) } WorkoutCommand::ChangeWeight { kg } if kg.is_finite() && *kg >= 0.0 => { let index = self.editable_exercise_index()?; let exercise = self.plan.get(index)?; Some(WorkoutEvent::WeightChanged { exercise: exercise.name.into(), kg: *kg, }) } WorkoutCommand::CorrectLastSet { kg } if kg.is_finite() && *kg >= 0.0 => self .last_completed_set() .map(|(exercise, set)| WorkoutEvent::SetEdited { exercise, set, kg: *kg, }), WorkoutCommand::SkipExercise if self.projection.phase != WorkoutPhase::Finished => { let exercise = self.current_exercise()?; Some(WorkoutEvent::ExerciseSkipped { exercise: exercise.name.into(), }) } WorkoutCommand::RecordNote { text } if !text.trim().is_empty() => { Some(WorkoutEvent::NoteRecorded { text: text.trim().into(), }) } _ => None, } } fn project(&mut self, event: &WorkoutEvent) { // req: local/001 req: local/004 match event { WorkoutEvent::SetCompleted { exercise, set, .. } => { if let Some(index) = self.exercise_index(exercise) { self.projection.current_exercise = index; } self.projection.completed_sets_for_current = *set; self.projection.total_completed_sets = self .projection .total_completed_sets .saturating_add(1) .min(self.total_target_sets()); self.projection.phase = if self.projection.total_completed_sets >= self.total_target_sets() { WorkoutPhase::ReadyToFinish } else { WorkoutPhase::Resting }; } WorkoutEvent::RestFinished { exercise, .. } => { if let Some(index) = self.exercise_index(exercise) { self.projection.current_exercise = index; self.projection.completed_sets_for_current = completed_sets_for(&self.events, exercise); } self.projection.phase = WorkoutPhase::Ready; } WorkoutEvent::WeightChanged { exercise, kg } | WorkoutEvent::SetEdited { exercise, kg, .. } => { if let Some(index) = self.exercise_index(exercise) { self.plan[index].kg = *kg; } } WorkoutEvent::ExerciseSkipped { exercise } => { let next = self .exercise_index(exercise) .unwrap_or(self.projection.current_exercise) + 1; self.projection.current_exercise = next; self.projection.completed_sets_for_current = 0; self.projection.phase = if next >= self.plan.len() { WorkoutPhase::ReadyToFinish } else { WorkoutPhase::Ready }; } WorkoutEvent::NoteRecorded { .. } => {} WorkoutEvent::WorkoutFinished { .. } => { self.projection.phase = WorkoutPhase::Finished; } } self.refresh_next_action(); self.activity.push_front(event.summary()); while self.activity.len() > 5 { self.activity.pop_back(); } } fn undo_last_event(&mut self) -> Option { // req: local/001 req: local/004 self.commands.push(WorkoutCommand::UndoLastAction); let removed = self.events.pop()?; let summary = removed.summary(); self.rebuild_projection(); self.activity.push_front(format!("undid {summary}")); while self.activity.len() > 5 { self.activity.pop_back(); } Some(summary) } fn rebuild_projection(&mut self) { let events = self.events.clone(); let host_status = self.host_status.clone(); let exported = self.projection.exported; self.plan = demo_plan(); self.projection = WorkoutProjection::start(); self.projection.exported = exported; self.activity.clear(); self.refresh_next_action(); for event in &events { self.project(event); } self.host_status = host_status; } fn refresh_next_action(&mut self) { self.projection.progress = self.progress_text(); match self.projection.phase { WorkoutPhase::Ready => { if let Some(exercise) = self.current_exercise() { let name = exercise.name; let target_sets = exercise.target_sets; let reps = exercise.reps; let kg = exercise.kg; self.projection.primary_action = "Complete set".into(); self.projection.next_action = format!( "Next: {name} set {}/{} · {reps} reps · {kg} kg", self.projection.completed_sets_for_current + 1, target_sets, ); } else { self.projection.phase = WorkoutPhase::ReadyToFinish; self.refresh_next_action(); } } WorkoutPhase::Resting => { let target = self .next_ready_target() .map(|(exercise, set)| format!("{exercise} set {set}")) .unwrap_or_else(|| "finish workout".into()); self.projection.primary_action = format!("Start {target}"); self.projection.next_action = format!("Rest 90s · next: {target}"); } WorkoutPhase::ReadyToFinish => { self.projection.primary_action = "Finish workout".into(); self.projection.next_action = "Workout complete · finish and export your session".into(); } WorkoutPhase::Finished => { self.projection.primary_action = "Session saved".into(); self.projection.next_action = "Session saved · export or replay anytime".into(); } } } fn current_exercise(&self) -> Option<&ExercisePlan> { self.plan.get(self.projection.current_exercise) } fn editable_exercise_index(&self) -> Option { match self.projection.phase { WorkoutPhase::Finished | WorkoutPhase::ReadyToFinish => None, WorkoutPhase::Resting if self.current_exercise().is_some_and(|exercise| { self.projection.completed_sets_for_current >= exercise.target_sets }) => { (self.projection.current_exercise + 1 < self.plan.len()) .then_some(self.projection.current_exercise + 1) } _ => self .current_exercise() .map(|_| self.projection.current_exercise), } } fn exercise_index(&self, name: &str) -> Option { self.plan.iter().position(|exercise| exercise.name == name) } fn last_completed_set(&self) -> Option<(String, u8)> { self.events.iter().rev().find_map(|event| match event { WorkoutEvent::SetCompleted { exercise, set, .. } => Some((exercise.clone(), *set)), _ => None, }) } fn next_ready_target(&self) -> Option<(&'static str, u8)> { let exercise = self.current_exercise()?; if self.projection.completed_sets_for_current >= exercise.target_sets { let next = self.plan.get(self.projection.current_exercise + 1)?; Some((next.name, 1)) } else { Some(( exercise.name, self.projection.completed_sets_for_current + 1, )) } } fn total_target_sets(&self) -> u8 { self.plan.iter().map(|exercise| exercise.target_sets).sum() } fn progress_text(&self) -> String { let total = self.total_target_sets().max(1); let done = self.projection.total_completed_sets.min(total); let percent = u16::from(done) * 100 / u16::from(total); format!("{done} of {total} sets · {percent}% complete") } pub fn event_log_text(&self) -> String { if self.events.is_empty() { return "No session events yet.".into(); } self.events .iter() .enumerate() .map(|(index, event)| format!("{}: {}", index + 1, event.summary())) .collect::>() .join("\n") } pub fn export_event_log(&self) -> String { // req: local/003 self.events .iter() .map(WorkoutEvent::to_export_line) .collect::>() .join("\n") } fn replay_export(&mut self, export: &str) -> Result { // req: local/001 req: local/003 req: local/004 let lines = export .lines() .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()); replayed.project(event); } replayed.host_status = format!( "Replayed {} exported events into a fresh projection; app policy still owns sync.", events.len() ); *self = replayed; 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 ") || last.starts_with("corrected ") => { 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(), } } } impl WorkoutProjection { fn start() -> Self { Self { current_exercise: 0, completed_sets_for_current: 0, total_completed_sets: 0, phase: WorkoutPhase::Ready, next_action: String::new(), primary_action: String::new(), progress: String::new(), exported: false, } } } fn demo_plan() -> Vec { vec![ ExercisePlan { name: "Goblet squat", target_sets: 3, reps: 8, kg: 24.0, }, ExercisePlan { name: "Push-up", target_sets: 2, reps: 10, kg: 0.0, }, ] } fn completed_sets_for(events: &[WorkoutEvent], exercise: &str) -> u8 { events .iter() .filter_map(|event| match event { WorkoutEvent::SetCompleted { exercise: event_exercise, set, .. } if event_exercise == exercise => Some(*set), _ => None, }) .max() .unwrap_or(0) } fn export_field(value: &str) -> String { value.replace(['\t', '\n', '\r'], " ") } impl WorkoutEvent { fn summary(&self) -> String { match self { Self::SetCompleted { exercise, set, reps, kg, } => { format!("completed {exercise} set {set}: {reps} reps @ {kg} kg") } Self::RestFinished { exercise, set } => format!("started {exercise} set {set}"), Self::WeightChanged { exercise, kg } => format!("changed {exercise} to {kg} kg"), Self::SetEdited { exercise, set, kg } => { format!("corrected {exercise} set {set} to {kg} kg") } Self::ExerciseSkipped { exercise } => format!("skipped {exercise}"), Self::NoteRecorded { text } => format!("note: {text}"), Self::WorkoutFinished { completed_sets, total_sets, } => format!("finished workout: {completed_sets}/{total_sets} sets complete"), } } fn to_export_line(&self) -> String { // req: local/003 match self { Self::SetCompleted { exercise, set, reps, kg, } => format!( "set_completed\t{}\t{set}\t{reps}\t{kg}", export_field(exercise) ), Self::RestFinished { exercise, set } => { format!("rest_finished\t{}\t{set}", export_field(exercise)) } Self::WeightChanged { exercise, kg } => { format!("weight_changed\t{}\t{kg}", export_field(exercise)) } Self::SetEdited { exercise, set, kg } => { format!("set_edited\t{}\t{set}\t{kg}", export_field(exercise)) } Self::ExerciseSkipped { exercise } => { format!("exercise_skipped\t{}", export_field(exercise)) } Self::NoteRecorded { text } => format!("note_recorded\t{}", export_field(text)), Self::WorkoutFinished { completed_sets, total_sets, } => format!("workout_finished\t{completed_sets}\t{total_sets}"), } } fn from_export_line(line: &str) -> Option { // req: local/003 let fields = line.split('\t').collect::>(); match fields.as_slice() { ["set_completed", exercise, set, reps, kg] => Some(Self::SetCompleted { exercise: (*exercise).into(), set: set.parse().ok()?, reps: reps.parse().ok()?, kg: kg.parse().ok()?, }), ["rest_finished", exercise, set] => Some(Self::RestFinished { exercise: (*exercise).into(), set: set.parse().ok()?, }), ["weight_changed", exercise, kg] => Some(Self::WeightChanged { exercise: (*exercise).into(), kg: kg.parse().ok()?, }), ["set_edited", exercise, set, kg] => Some(Self::SetEdited { exercise: (*exercise).into(), set: set.parse().ok()?, kg: kg.parse().ok()?, }), ["exercise_skipped", exercise] => Some(Self::ExerciseSkipped { exercise: (*exercise).into(), }), ["note_recorded", text] => Some(Self::NoteRecorded { text: (*text).into(), }), ["workout_finished", completed_sets, total_sets] => Some(Self::WorkoutFinished { completed_sets: completed_sets.parse().ok()?, total_sets: total_sets.parse().ok()?, }), _ => None, } } } #[derive(Hemplate)] pub struct Workout { pub next_action: String, pub primary_action: String, pub status: String, pub progress: String, pub recovery_status: String, pub finish_title: String, pub finish_copy: String, pub share_action: String, pub event_log: String, pub export_payload: String, pub host_status: String, } #[derive(Hemplate)] pub struct AppShell { pub runtime_src: &'static str, pub body: Html, } pub fn page(runtime_src: &'static str, state: &WorkoutState) -> Html { ui::page(&AppShell { runtime_src, body: render(state), }) } pub fn view(state: &WorkoutState) -> Workout { let export = state.export_event_log(); let has_events = !state.events.is_empty(); let (finish_title, finish_copy, share_action) = match state.projection.phase { WorkoutPhase::Ready | WorkoutPhase::Resting if !has_events => ( "Finish unlocks as you train".into(), "Complete a set to create a replayable local export.".into(), "Export after first set".into(), ), WorkoutPhase::Ready | WorkoutPhase::Resting => ( "Session log is ready".into(), "Your progress is already replayable; finish all sets for the final export moment.".into(), "Share current log".into(), ), WorkoutPhase::ReadyToFinish => ( "All sets complete".into(), "Finish to save the workout summary, then share or replay the exact local event log.".into(), "Finish, then share".into(), ), WorkoutPhase::Finished if state.projection.exported => ( "Export shared".into(), "The shared text is still just a copy; this local event log remains the source.".into(), "Share again".into(), ), WorkoutPhase::Finished => ( "Workout saved".into(), "Share the final event log, or replay it locally to prove recovery without hidden browser state.".into(), "Share final export".into(), ), }; Workout { next_action: state.projection.next_action.clone(), primary_action: state.projection.primary_action.clone(), status: state.projection.progress.clone(), progress: state.projection.progress.clone(), recovery_status: state.recovery_text(), finish_title, finish_copy, share_action, event_log: state.event_log_text(), export_payload: if export.is_empty() { "Complete a set to create a replayable export.".into() } else { export }, host_status: state.host_status.clone(), } } pub fn render(state: &WorkoutState) -> Html { ui::page(&view(state)) } #[derive(Clone, Debug)] #[hemx::form("change_weight")] pub struct ChangeWeightInput { kg: String, } #[derive(Clone, Debug)] #[hemx::form("correct_last_set")] pub struct CorrectLastSetInput { kg: String, } #[derive(Clone, Debug)] #[hemx::form("record_note")] pub struct RecordNoteInput { text: String, } pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry { // req: codegen/002 req: examples/001 hemx_axum::state_interactions(ui::BUILD_FINGERPRINT, state) .on_state(ui::workout::complete_set, complete_set) .on(ui::workout::change_weight, change_weight_form) .on(ui::workout::correct_last_set, correct_last_set_form) .on_state(ui::workout::skip_exercise, skip_exercise) .on(ui::workout::record_note, record_note_form) .on_state(ui::workout::undo_last_action, undo_last_action) .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, record_native_haptic_ack, ) } fn effects(state: &WorkoutState, status: impl Into) -> impl IntoEffect { // req: local/001 req: local/004 let view = view(state); ( ui::workout::next_action.text(&view.next_action), ui::workout::primary_action.text(&view.primary_action), ui::workout::status.text(status.into()), ui::workout::progress.text(&view.progress), ui::workout::recovery_status.text(&view.recovery_status), ui::workout::finish_title.text(&view.finish_title), ui::workout::finish_copy.text(&view.finish_copy), ui::workout::share_action.text(&view.share_action), ui::workout::event_log.text(&view.event_log), ui::workout::export_payload.text(&view.export_payload), ui::workout::host_status.text(&view.host_status), ) } pub fn complete_set(app: AppState) -> impl IntoEffect { app.update(|state| { let command = state.primary_command(); let event = state.accept(command); if matches!(event, Some(WorkoutEvent::SetCompleted { .. })) { state.host_status = "Set saved locally; native haptic can acknowledge it without owning state.".into(); } effects( state, event.map_or_else( || "Session is already saved; export is ready.".into(), |event| event.summary(), ), ) }) } pub fn change_weight(app: AppState, kg: f32) -> impl IntoEffect { app.update(|state| { 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(), ), ) }) } pub fn correct_last_set(app: AppState, kg: f32) -> impl IntoEffect { app.update(|state| { let event = state.accept(WorkoutCommand::CorrectLastSet { kg }); effects( state, event.map_or_else( || "Complete a set before correcting its recorded weight.".into(), |event| event.summary(), ), ) }) } pub fn skip_exercise(app: AppState) -> impl IntoEffect { app.update(|state| { let event = state.accept(WorkoutCommand::SkipExercise); effects( state, event.map_or_else( || "No exercise available to skip.".into(), |event| event.summary(), ), ) }) } pub fn undo_last_action(app: AppState) -> impl IntoEffect { app.update(|state| { let undone = state.undo_last_event(); effects( state, undone.map_or_else( || "Nothing to undo yet; complete a set first.".into(), |summary| format!("Undid: {summary}"), ), ) }) } pub fn change_weight_form(app: AppState, Form(input): Form) -> impl IntoEffect { 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 correct_last_set_form( app: AppState, Form(input): Form, ) -> impl IntoEffect { app.update(|state| match input.kg.trim().parse::() { Ok(kg) if kg.is_finite() && kg >= 0.0 => { let event = state.accept(WorkoutCommand::CorrectLastSet { kg }); effects( state, event.map_or_else( || "Complete a set before correcting its recorded weight.".into(), |event| event.summary(), ), ) } _ => effects( state, String::from("Enter a valid corrected weight; your session is unchanged."), ), }) } pub fn record_note_form(app: AppState, Form(input): Form) -> impl IntoEffect { record_note(app, input.text) } pub fn record_note(app: AppState, text: impl Into) -> impl IntoEffect { app.update(|state| { let event = state.accept(WorkoutCommand::RecordNote { text: text.into() }); effects( state, event.map_or_else( || "Ignored empty note; nothing changed.".into(), |event| event.summary(), ), ) }) } 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(); 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."), ), } }) } pub fn export_log(app: AppState) -> impl IntoEffect { app.update(|state| { // req: host/001 req: host/004 req: local/003 if state.events.is_empty() { state.host_status = "Complete a set before opening the share sheet.".into(); return effects(state, "Nothing to export yet"); } let manifest = CapabilityManifest::new([CapabilityUse::new( Capability::Share, CapabilityShape::Request, )]); let call = HostCall::Share { id: HostCallId::new("workout-export"), payload: HostShareData::text(state.export_event_log()), }; state.host_status = match manifest.validate_call(&browser_pwa_host_profile(), &call) { Ok(()) => "Share sheet requested; your local event log remains the source.".into(), Err(error) => format!("Share unavailable: {error}. Export text stays below."), }; effects(state, "Share export prepared") }) } pub fn record_share_result(app: AppState) -> impl IntoEffect { app.update(|state| { // req: host/002 req: host/005 req: local/003 apply_host_event( state, HostEvent::ShareCompleted { id: HostCallId::new("workout-export"), completed: true, }, ); effects(state, "Share result returned through app code") }) } 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::Failed( HostFailure::new(HostFailureKind::PermissionDenied, "share permission denied") .with_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( HostFailure::new(HostFailureKind::Timeout, "share timed out") .with_id(HostCallId::new("workout-export")) .with_capability(Capability::Share), ), ); 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 let manifest = CapabilityManifest::new([CapabilityUse::new( Capability::Haptics, CapabilityShape::Fire, )]); let call = HostCall::Haptic { id: HostCallId::new("workout-set-haptic"), pattern: HapticPattern::Success, }; state.host_status = match manifest.validate_call( &native_shell_host_profile("ios-android-webview-workout"), &call, ) { Ok(()) => "Native-shell haptic requested for the saved set.".into(), Err(error) => format!("Native haptic unavailable: {error}"), }; effects(state, "Native-shell host call checked against manifest") }) } pub fn record_native_haptic_ack(app: AppState) -> impl IntoEffect { app.update(|state| { // req: host/002 req: host/005 apply_host_event( state, HostEvent::Acknowledged { id: HostCallId::new("workout-set-haptic"), }, ); effects(state, "Native-shell host result accepted by app code") }) } fn host_failure_label(kind: HostFailureKind) -> &'static str { match kind { HostFailureKind::PermissionDenied => "permission denied", HostFailureKind::Timeout => "timeout", HostFailureKind::Unavailable => "unavailable", HostFailureKind::Error => "error", } } fn apply_host_event(state: &mut WorkoutState, event: HostEvent) { match event { HostEvent::ShareCompleted { completed: true, .. } => { state.projection.exported = true; state.host_status = "Shared. The replayable event log remains local truth.".into(); } HostEvent::ShareCompleted { completed: false, .. } => { state.host_status = "Share cancelled; local event log unchanged. Try Share export when ready.".into(); } HostEvent::Failed(failure) if failure.kind == HostFailureKind::PermissionDenied && failure.capability == Some(Capability::Share) => { state.host_status = "Share permission denied. Local export text is still ready; try again or copy it." .into(); } HostEvent::Failed(failure) => { state.host_status = format!( "Host {}: {}. Keep the local export below and try Share export again.", host_failure_label(failure.kind), failure.message ); } HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => { state.host_status = "Native haptic acknowledged; workout state stayed app-owned.".into(); } _ => { state.host_status = "Host event ignored by app policy.".into(); } } } #[cfg(test)] mod tests { use super::*; use hemx::advanced::{Effect, Payload}; use hemx_test::run; fn contains_payload_text(effects: &hemx_test::EffectInspector, needle: &str) -> bool { effects.batch().ops.iter().any(|op| { matches!(op, Effect::Put { payload: Payload::Text(text), .. } if text.contains(needle)) }) } #[test] fn local_command_projects_before_ui_effects() { // req: local/001 req: local/004 let app = AppState::demo(); let effects = run(|()| complete_set(app.clone()), ()); assert_eq!( app.with_workout(|state| ( state.commands.len(), state.events.len(), state.projection.completed_sets_for_current, state.projection.phase.clone(), )), (1, 1, 1, WorkoutPhase::Resting) ); assert!(contains_payload_text( &effects, "completed Goblet squat set 1" )); assert!(contains_payload_text(&effects, "Rest 90s")); assert!(contains_payload_text(&effects, "Start Goblet squat set 2")); assert_eq!( app.with_workout(|state| (state.commands.len(), state.events.len())), (1, 1) ); } #[test] fn primary_action_moves_from_rest_to_next_set_and_finish() { // req: examples/001 req: local/001 req: local/004 let app = AppState::demo(); run(|()| complete_set(app.clone()), ()); let rest = run(|()| complete_set(app.clone()), ()); assert!(contains_payload_text(&rest, "started Goblet squat set 2")); assert_eq!( app.with_workout(|state| state.projection.phase.clone()), WorkoutPhase::Ready ); for _ in 0..7 { run(|()| complete_set(app.clone()), ()); } assert_eq!( app.with_workout(|state| state.projection.phase.clone()), WorkoutPhase::ReadyToFinish ); let finished = run(|()| complete_set(app.clone()), ()); assert!(contains_payload_text( &finished, "finished workout: 5/5 sets complete" )); assert!(contains_payload_text(&finished, "Workout saved")); assert!(contains_payload_text(&finished, "Share final export")); assert_eq!( app.with_workout(|state| state.projection.phase.clone()), WorkoutPhase::Finished ); } #[test] fn double_primary_action_recovers_through_undo() { // req: examples/001 req: local/001 req: local/004 let app = AppState::demo(); run(|()| complete_set(app.clone()), ()); let double_action = run(|()| complete_set(app.clone()), ()); assert!(contains_payload_text( &double_action, "started Goblet squat set 2" )); assert_eq!( app.with_workout(|state| (state.events.len(), state.projection.phase.clone())), (2, WorkoutPhase::Ready) ); let undone = run(|()| undo_last_action(app.clone()), ()); assert!(contains_payload_text( &undone, "Undid: started Goblet squat set 2" )); assert!(contains_payload_text(&undone, "Rest 90s")); assert_eq!( app.with_workout(|state| (state.events.len(), state.projection.phase.clone())), (1, WorkoutPhase::Resting) ); } #[test] fn correct_last_set_is_replayable_and_undoable() { // req: examples/001 req: local/001 req: local/003 req: local/004 let app = AppState::demo(); run(|()| complete_set(app.clone()), ()); let corrected = run(|()| correct_last_set(app.clone(), 26.0), ()); assert!(contains_payload_text( &corrected, "corrected Goblet squat set 1 to 26 kg" )); assert_eq!( app.with_workout(|state| (state.events.len(), state.plan[0].kg)), (2, 26.0) ); let export = app.with_workout(WorkoutState::export_event_log); assert!(export.contains("set_edited\tGoblet squat\t1\t26")); let replay = run(|()| replay_export(app.clone()), ()); assert!(contains_payload_text( &replay, "Replayed 2 exported workout events" )); assert_eq!(app.with_workout(|state| state.plan[0].kg), 26.0); let undone = run(|()| undo_last_action(app.clone()), ()); assert!(contains_payload_text( &undone, "Undid: corrected Goblet squat set 1 to 26 kg" )); assert_eq!(app.with_workout(|state| state.plan[0].kg), 24.0); } #[test] fn skipped_exercise_recovers_through_undo() { // req: examples/001 req: local/001 req: local/004 let app = AppState::demo(); let skipped = run(|()| skip_exercise(app.clone()), ()); assert!(contains_payload_text(&skipped, "skipped Goblet squat")); assert!(contains_payload_text(&skipped, "Next: Push-up set 1/2")); assert_eq!( app.with_workout(|state| ( state.events.len(), state.projection.current_exercise, state.projection.phase.clone(), )), (1, 1, WorkoutPhase::Ready) ); let undone = run(|()| undo_last_action(app.clone()), ()); assert!(contains_payload_text( &undone, "Undid: skipped Goblet squat" )); assert!(contains_payload_text(&undone, "Next: Goblet squat set 1/3")); assert_eq!( app.with_workout(|state| ( state.events.len(), state.projection.current_exercise, state.projection.phase.clone(), )), (0, 0, WorkoutPhase::Ready) ); } #[test] fn undo_recovers_last_session_action() { // req: examples/001 req: local/001 req: local/004 let app = AppState::demo(); run(|()| complete_set(app.clone()), ()); let undone = run(|()| undo_last_action(app.clone()), ()); assert!(contains_payload_text( &undone, "Undid: completed Goblet squat set 1" )); assert_eq!( app.with_workout(|state| ( state.events.len(), state.projection.completed_sets_for_current, state.projection.phase.clone(), )), (0, 0, WorkoutPhase::Ready) ); } #[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 let app = AppState::demo(); run(|()| complete_set(app.clone()), ()); run(|()| complete_set(app.clone()), ()); run(|()| change_weight(app.clone(), 28.0), ()); let export = app.with_workout(WorkoutState::export_event_log); assert!(export.contains("set_completed\tGoblet squat\t1\t8\t24")); assert!(export.contains("rest_finished\tGoblet squat\t2")); assert!(export.contains("weight_changed\tGoblet squat\t28")); let replay = run(|()| replay_export(app.clone()), ()); assert!(contains_payload_text( &replay, "Replayed 3 exported workout events" )); assert_eq!( app.with_workout(|state| { ( state.events.len(), state.projection.completed_sets_for_current, state.plan[0].kg, state.projection.phase.clone(), ) }), (3, 1, 28.0, WorkoutPhase::Ready) ); } #[test] fn host_export_result_returns_through_app_code() { // req: host/001 req: host/002 req: host/005 req: local/003 let app = AppState::demo(); run(|()| complete_set(app.clone()), ()); let export = run(|()| export_log(app.clone()), ()); assert!(contains_payload_text(&export, "Share export prepared")); assert!(contains_payload_text(&export, "Share sheet requested")); let shared = run(|()| record_share_result(app.clone()), ()); assert!(app.with_workout(|state| state.projection.exported)); assert!(contains_payload_text( &shared, "Share result returned through app code" )); assert!(contains_payload_text( &shared, "replayable event log remains local truth" )); } #[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 let app = AppState::demo(); let request = run(|()| request_native_haptic(app.clone()), ()); assert!(contains_payload_text( &request, "Native-shell host call checked against manifest" )); assert!(contains_payload_text( &request, "Native-shell haptic requested" )); let ack = run(|()| record_native_haptic_ack(app.clone()), ()); assert!(contains_payload_text( &ack, "Native-shell host result accepted by app code" )); assert!(contains_payload_text( &ack, "workout state stayed app-owned" )); } #[test] fn rendered_page_has_phone_first_controls_without_user_js() { // req: examples/001 req: examples/005 let html = render(&WorkoutState::demo()).to_string(); assert!(html.contains("Now-first Workout Copilot")); assert!(html.contains("Complete set")); assert!(html.contains("Finish unlocks as you train")); assert!(html.contains("Export after first set")); assert!(html.contains("Undo last action")); assert!(html.contains("Correct last set")); 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")); } }