Files
hemx/examples/workout/src/lib.rs
T
slhx agent a4a4dcf74f feat(workout): hide registry conversion from golden path
Let the Workout generated interaction builder satisfy DispatchRegistry directly and guard canonical sources/docs against .into_registry() so the beginner path does not teach manual registry conversion.

req: examples/001

req: examples/006

req: public_api/002
2026-06-12 13:08:46 +02:00

1379 lines
47 KiB
Rust

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<ExercisePlan>,
pub commands: Vec<WorkoutCommand>,
pub events: Vec<WorkoutEvent>,
pub projection: WorkoutProjection,
pub activity: VecDeque<String>,
pub host_status: String,
}
#[derive(Clone)]
pub struct AppState {
workout: Arc<Mutex<WorkoutState>>,
}
impl AppState {
pub fn demo() -> Self {
Self {
workout: Arc::new(Mutex::new(WorkoutState::demo())),
}
}
pub fn with_workout<R>(&self, f: impl FnOnce(&WorkoutState) -> R) -> R {
let workout = self.workout.lock().unwrap();
f(&workout)
}
fn update<R>(&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<WorkoutEvent> {
// 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<WorkoutEvent> {
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<String> {
// 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<usize> {
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<usize> {
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::<Vec<_>>()
.join("\n")
}
pub fn export_event_log(&self) -> String {
// req: local/003
self.events
.iter()
.map(WorkoutEvent::to_export_line)
.collect::<Vec<_>>()
.join("\n")
}
fn replay_export(&mut self, export: &str) -> Result<usize, String> {
// req: local/001 req: local/003 req: local/004
let lines = export
.lines()
.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());
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<ExercisePlan> {
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<Self> {
// req: local/003
let fields = line.split('\t').collect::<Vec<_>>();
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<String>) -> 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<ChangeWeightInput>) -> impl IntoEffect {
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 correct_last_set_form(
app: AppState,
Form(input): Form<CorrectLastSetInput>,
) -> impl IntoEffect {
app.update(|state| match input.kg.trim().parse::<f32>() {
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<RecordNoteInput>) -> impl IntoEffect {
record_note(app, input.text)
}
pub fn record_note(app: AppState, text: impl Into<String>) -> 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"));
}
}