feat(workout): complete mobile session flow

Make Workout Copilot behave like a finished phone-first session: rest/next state, progress, undo recovery, finish/export, replayable local log, and host proof controls tucked behind product states.

req: examples/001

req: local/001

req: local/003

req: local/004

req: host/002
This commit is contained in:
slhx agent
2026-06-11 23:15:55 +02:00
parent bf7560b7a8
commit a514237b09
7 changed files with 561 additions and 161 deletions
+1 -1
View File
@@ -795,7 +795,7 @@ what a valid business email is.
## examples ## examples
### req: examples/001 ### 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 host export 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, 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 ### 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. 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.
+3 -2
View File
@@ -1,12 +1,13 @@
# Workout exemplar design # Workout exemplar design
Direction: gym-floor command slate — one heavy next action, thumb-safe command rail, and an inspectable private ledger. req: examples/001 Direction: gym-floor command slate — one heavy next action, thumb-safe command rail, finished-session export, and an inspectable private ledger. req: examples/001
Preserve: Preserve:
- First viewport shows one obvious action path before logs or host details. - 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. - 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.
- Local truth is visible as a ledger/export, not hidden behind dashboard metrics. req: local/001 req: local/003 - Local truth is visible as a ledger/export, not hidden behind dashboard metrics. req: local/001 req: local/003
- Host capabilities read as a boundary surface, not as app state or a native framework. req: host/002 - 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
Avoid: Avoid:
- Dashboard/KPI cards, fake charts, bottom-nav app chrome, glass/gradient hero filler, and hidden client state. - Dashboard/KPI cards, fake charts, bottom-nav app chrome, glass/gradient hero filler, and hidden client state.
+9 -6
View File
@@ -1,10 +1,10 @@
# Now-first Workout Copilot example # Now-first Workout Copilot example
This is the phone-first local-first product exemplar for hemx. It shows a This is the phone-first local-first product exemplar for hemx. It shows a
single useful loop: next action shown, user input accepted, a local command complete useful session: next action shown, set completed, rest/next state
recorded, a domain event projected, hemx UI updated, and the result exported or entered, progress updated, mistakes undone, workout finished, and the result
replayed without storing DOM patches or UI update payloads as truth. req: examples/001 exported or replayed without storing DOM patches or UI update payloads as truth.
req: local/001 req: local/003 req: local/004 req: examples/001 req: local/001 req: local/003 req: local/004
## Run ## Run
@@ -31,7 +31,9 @@ cargo test -p hemx-workout-example
The E2E test starts the real HTTP binary, loads the page, checks the shared The E2E test starts the real HTTP binary, loads the page, checks the shared
runtime asset, submits workout interactions, decodes hemx effect responses, and runtime asset, submits workout interactions, decodes hemx effect responses, and
replays exported events back into a projection. req: test/001 req: examples/001 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
For the full repository gate: For the full repository gate:
@@ -57,7 +59,8 @@ ship. req: axum_integration/005 req: examples/005
## Boundaries proven ## Boundaries proven
- Core workout logging works without network availability once the page/runtime - Core workout logging works without network availability once the page/runtime
is loaded: commands/events/projections are app truth. req: local/001 is loaded: commands/events/projections are app truth, including rest/next,
finish, and undo recovery. req: local/001
- Browser/PWA export uses the host capability contract and the host result - 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 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 - Native-shell-shaped haptic acknowledgment uses the same host call/event path
+442 -109
View File
@@ -23,9 +23,12 @@ pub struct ExercisePlan {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum WorkoutCommand { pub enum WorkoutCommand {
CompleteSet, CompleteSet,
StartNextSet,
FinishWorkout,
ChangeWeight { kg: f32 }, ChangeWeight { kg: f32 },
SkipExercise, SkipExercise,
RecordNote { text: String }, RecordNote { text: String },
UndoLastAction,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -36,6 +39,10 @@ pub enum WorkoutEvent {
reps: u8, reps: u8,
kg: f32, kg: f32,
}, },
RestFinished {
exercise: String,
set: u8,
},
WeightChanged { WeightChanged {
exercise: String, exercise: String,
kg: f32, kg: f32,
@@ -46,13 +53,29 @@ pub enum WorkoutEvent {
NoteRecorded { NoteRecorded {
text: String, text: String,
}, },
WorkoutFinished {
completed_sets: u8,
total_sets: u8,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum WorkoutPhase {
Ready,
Resting,
ReadyToFinish,
Finished,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct WorkoutProjection { pub struct WorkoutProjection {
pub current_exercise: usize, pub current_exercise: usize,
pub completed_sets_for_current: u8, pub completed_sets_for_current: u8,
pub total_completed_sets: u8,
pub phase: WorkoutPhase,
pub next_action: String, pub next_action: String,
pub primary_action: String,
pub progress: String,
pub exported: bool, pub exported: bool,
} }
@@ -92,35 +115,25 @@ impl AppState {
impl WorkoutState { impl WorkoutState {
pub fn demo() -> Self { pub fn demo() -> Self {
let mut state = Self { let mut state = Self {
plan: vec![ plan: demo_plan(),
ExercisePlan {
name: "Goblet squat",
target_sets: 3,
reps: 8,
kg: 24.0,
},
ExercisePlan {
name: "Push-up",
target_sets: 2,
reps: 10,
kg: 0.0,
},
],
commands: Vec::new(), commands: Vec::new(),
events: Vec::new(), events: Vec::new(),
projection: WorkoutProjection { projection: WorkoutProjection::start(),
current_exercise: 0,
completed_sets_for_current: 0,
next_action: String::new(),
exported: false,
},
activity: VecDeque::new(), activity: VecDeque::new(),
host_status: "Export waits for an explicit host result.".into(), host_status: "Ready to keep the session private until you export.".into(),
}; };
state.refresh_next_action(); state.refresh_next_action();
state 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> { fn accept(&mut self, command: WorkoutCommand) -> Option<WorkoutEvent> {
// req: local/001 req: local/004 // req: local/001 req: local/004
let event = self.validate(&command)?; let event = self.validate(&command)?;
@@ -131,52 +144,104 @@ impl WorkoutState {
} }
fn validate(&self, command: &WorkoutCommand) -> Option<WorkoutEvent> { fn validate(&self, command: &WorkoutCommand) -> Option<WorkoutEvent> {
let exercise = self.plan.get(self.projection.current_exercise)?;
match command { match command {
WorkoutCommand::CompleteSet => Some(WorkoutEvent::SetCompleted { WorkoutCommand::CompleteSet if self.projection.phase == WorkoutPhase::Ready => {
exercise: exercise.name.into(), let exercise = self.current_exercise()?;
set: self.projection.completed_sets_for_current + 1, Some(WorkoutEvent::SetCompleted {
reps: exercise.reps, exercise: exercise.name.into(),
kg: exercise.kg, set: self.projection.completed_sets_for_current + 1,
}), reps: exercise.reps,
WorkoutCommand::ChangeWeight { kg } => Some(WorkoutEvent::WeightChanged { kg: exercise.kg,
exercise: exercise.name.into(), })
kg: *kg, }
}), WorkoutCommand::StartNextSet if self.projection.phase == WorkoutPhase::Resting => {
WorkoutCommand::SkipExercise => Some(WorkoutEvent::ExerciseSkipped { let (exercise, set) = self.next_ready_target()?;
exercise: exercise.name.into(), 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::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() => { WorkoutCommand::RecordNote { text } if !text.trim().is_empty() => {
Some(WorkoutEvent::NoteRecorded { Some(WorkoutEvent::NoteRecorded {
text: text.trim().into(), text: text.trim().into(),
}) })
} }
WorkoutCommand::RecordNote { .. } => None, _ => None,
} }
} }
fn project(&mut self, event: &WorkoutEvent) { fn project(&mut self, event: &WorkoutEvent) {
// req: local/001 req: local/004 // req: local/001 req: local/004
match event { match event {
WorkoutEvent::SetCompleted { set, .. } => { 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.completed_sets_for_current = *set;
if let Some(exercise) = self.plan.get(self.projection.current_exercise) { self.projection.total_completed_sets = self
if *set >= exercise.target_sets { .projection
self.projection.current_exercise += 1; .total_completed_sets
self.projection.completed_sets_for_current = 0; .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 } => {
if let Some(index) = self.exercise_index(exercise) {
self.plan[index].kg = *kg;
} }
} }
WorkoutEvent::WeightChanged { kg, .. } => { WorkoutEvent::ExerciseSkipped { exercise } => {
if let Some(exercise) = self.plan.get_mut(self.projection.current_exercise) { let next = self
exercise.kg = *kg; .exercise_index(exercise)
} .unwrap_or(self.projection.current_exercise)
} + 1;
WorkoutEvent::ExerciseSkipped { .. } => { self.projection.current_exercise = next;
self.projection.current_exercise += 1;
self.projection.completed_sets_for_current = 0; self.projection.completed_sets_for_current = 0;
self.projection.phase = if next >= self.plan.len() {
WorkoutPhase::ReadyToFinish
} else {
WorkoutPhase::Ready
};
} }
WorkoutEvent::NoteRecorded { .. } => {} WorkoutEvent::NoteRecorded { .. } => {}
WorkoutEvent::WorkoutFinished { .. } => {
self.projection.phase = WorkoutPhase::Finished;
}
} }
self.refresh_next_action(); self.refresh_next_action();
self.activity.push_front(event.summary()); self.activity.push_front(event.summary());
@@ -185,26 +250,126 @@ impl WorkoutState {
} }
} }
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) { fn refresh_next_action(&mut self) {
self.projection.next_action = self self.projection.progress = self.progress_text();
.plan match self.projection.phase {
.get(self.projection.current_exercise) WorkoutPhase::Ready => {
.map(|exercise| { if let Some(exercise) = self.current_exercise() {
format!( let name = exercise.name;
"Next: {} set {}/{} · {} reps · {} kg", let target_sets = exercise.target_sets;
exercise.name, let reps = exercise.reps;
self.projection.completed_sets_for_current + 1, let kg = exercise.kg;
exercise.target_sets, self.projection.primary_action = "Complete set".into();
exercise.reps, self.projection.next_action = format!(
exercise.kg "Next: {name} set {}/{} · {reps} reps · {kg} kg",
) self.projection.completed_sets_for_current + 1,
}) target_sets,
.unwrap_or_else(|| "Workout complete · export or replay the event log".into()); );
} 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 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 { pub fn event_log_text(&self) -> String {
if self.events.is_empty() { if self.events.is_empty() {
return "[]".into(); return "No session events yet.".into();
} }
self.events self.events
.iter() .iter()
@@ -243,6 +408,53 @@ impl WorkoutState {
} }
} }
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 { fn export_field(value: &str) -> String {
value.replace(['\t', '\n', '\r'], " ") value.replace(['\t', '\n', '\r'], " ")
} }
@@ -258,9 +470,14 @@ impl WorkoutEvent {
} => { } => {
format!("completed {exercise} set {set}: {reps} reps @ {kg} 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::WeightChanged { exercise, kg } => format!("changed {exercise} to {kg} kg"),
Self::ExerciseSkipped { exercise } => format!("skipped {exercise}"), Self::ExerciseSkipped { exercise } => format!("skipped {exercise}"),
Self::NoteRecorded { text } => format!("note: {text}"), Self::NoteRecorded { text } => format!("note: {text}"),
Self::WorkoutFinished {
completed_sets,
total_sets,
} => format!("finished workout: {completed_sets}/{total_sets} sets complete"),
} }
} }
@@ -276,6 +493,9 @@ impl WorkoutEvent {
"set_completed\t{}\t{set}\t{reps}\t{kg}", "set_completed\t{}\t{set}\t{reps}\t{kg}",
export_field(exercise) export_field(exercise)
), ),
Self::RestFinished { exercise, set } => {
format!("rest_finished\t{}\t{set}", export_field(exercise))
}
Self::WeightChanged { exercise, kg } => { Self::WeightChanged { exercise, kg } => {
format!("weight_changed\t{}\t{kg}", export_field(exercise)) format!("weight_changed\t{}\t{kg}", export_field(exercise))
} }
@@ -283,6 +503,10 @@ impl WorkoutEvent {
format!("exercise_skipped\t{}", export_field(exercise)) format!("exercise_skipped\t{}", export_field(exercise))
} }
Self::NoteRecorded { text } => format!("note_recorded\t{}", export_field(text)), 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}"),
} }
} }
@@ -296,6 +520,10 @@ impl WorkoutEvent {
reps: reps.parse().ok()?, reps: reps.parse().ok()?,
kg: kg.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 { ["weight_changed", exercise, kg] => Some(Self::WeightChanged {
exercise: (*exercise).into(), exercise: (*exercise).into(),
kg: kg.parse().ok()?, kg: kg.parse().ok()?,
@@ -306,6 +534,10 @@ impl WorkoutEvent {
["note_recorded", text] => Some(Self::NoteRecorded { ["note_recorded", text] => Some(Self::NoteRecorded {
text: (*text).into(), 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, _ => None,
} }
} }
@@ -314,7 +546,10 @@ impl WorkoutEvent {
#[derive(Hemplate)] #[derive(Hemplate)]
pub struct Workout { pub struct Workout {
pub next_action: String, pub next_action: String,
pub primary_action: String,
pub status: String, pub status: String,
pub progress: String,
pub recovery_status: String,
pub event_log: String, pub event_log: String,
pub export_payload: String, pub export_payload: String,
pub host_status: String, pub host_status: String,
@@ -334,15 +569,23 @@ pub fn page(runtime_src: &'static str, state: &WorkoutState) -> Html {
} }
pub fn view(state: &WorkoutState) -> Workout { pub fn view(state: &WorkoutState) -> Workout {
let export = state.export_event_log();
Workout { Workout {
next_action: state.projection.next_action.clone(), next_action: state.projection.next_action.clone(),
status: format!( primary_action: state.projection.primary_action.clone(),
"{} local commands · {} domain events · projection is the UI source", status: state.projection.progress.clone(),
state.commands.len(), progress: state.projection.progress.clone(),
state.events.len() 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()),
event_log: state.event_log_text(), event_log: state.event_log_text(),
export_payload: state.export_event_log(), export_payload: if export.is_empty() {
"Complete a set to create a replayable export.".into()
} else {
export
},
host_status: state.host_status.clone(), host_status: state.host_status.clone(),
} }
} }
@@ -370,31 +613,47 @@ pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry {
.on(ui::workout::change_weight, change_weight_form) .on(ui::workout::change_weight, change_weight_form)
.on_state(ui::workout::skip_exercise, skip_exercise) .on_state(ui::workout::skip_exercise, skip_exercise)
.on(ui::workout::record_note, record_note_form) .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::replay_export, replay_export)
.on_state(ui::workout::export_log, export_log) .on_state(ui::workout::export_log, export_log)
.on_state(ui::workout::record_share_result, record_share_result) .on_state(ui::workout::record_share_result, record_share_result)
.on_state(ui::workout::request_native_haptic, request_native_haptic) .on_state(ui::workout::request_native_haptic, request_native_haptic)
.on_state(ui::workout::record_native_haptic_ack, record_native_haptic_ack) .on_state(
ui::workout::record_native_haptic_ack,
record_native_haptic_ack,
)
.into_registry() .into_registry()
} }
fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect { fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect {
// req: local/001 req: local/004 // req: local/001 req: local/004
let view = view(state);
( (
ui::workout::next_action.text(&state.projection.next_action), ui::workout::next_action.text(&view.next_action),
ui::workout::primary_action.text(&view.primary_action),
ui::workout::status.text(status.into()), ui::workout::status.text(status.into()),
ui::workout::event_log.text(state.event_log_text()), ui::workout::progress.text(&view.progress),
ui::workout::export_payload.text(state.export_event_log()), ui::workout::recovery_status.text(&view.recovery_status),
ui::workout::host_status.text(&state.host_status), 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 { pub fn complete_set(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
let event = state.accept(WorkoutCommand::CompleteSet); 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( effects(
state, state,
event.map_or_else(|| "No set available".into(), |event| event.summary()), event.map_or_else(
|| "Session is already saved; export is ready.".into(),
|event| event.summary(),
),
) )
}) })
} }
@@ -404,7 +663,10 @@ pub fn change_weight(app: AppState, kg: f32) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::ChangeWeight { kg }); let event = state.accept(WorkoutCommand::ChangeWeight { kg });
effects( effects(
state, state,
event.map_or_else(|| "No exercise available".into(), |event| event.summary()), event.map_or_else(
|| "Enter a valid weight for the current or next exercise.".into(),
|event| event.summary(),
),
) )
}) })
} }
@@ -414,7 +676,23 @@ pub fn skip_exercise(app: AppState) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::SkipExercise); let event = state.accept(WorkoutCommand::SkipExercise);
effects( effects(
state, state,
event.map_or_else(|| "No exercise available".into(), |event| event.summary()), 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}"),
),
) )
}) })
} }
@@ -432,7 +710,10 @@ pub fn record_note(app: AppState, text: impl Into<String>) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::RecordNote { text: text.into() }); let event = state.accept(WorkoutCommand::RecordNote { text: text.into() });
effects( effects(
state, state,
event.map_or_else(|| "Ignored empty note".into(), |event| event.summary()), event.map_or_else(
|| "Ignored empty note; nothing changed.".into(),
|event| event.summary(),
),
) )
}) })
} }
@@ -449,19 +730,23 @@ pub fn replay_export(app: AppState) -> impl IntoEffect {
pub fn export_log(app: AppState) -> impl IntoEffect { pub fn export_log(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
// req: host/001 req: host/004 req: local/003 // 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( let manifest = CapabilityManifest::new([CapabilityUse::new(
Capability::Share, Capability::Share,
CapabilityShape::Request, CapabilityShape::Request,
)]); )]);
let call = HostCall::Share { let call = HostCall::Share {
id: HostCallId::new("workout-export"), id: HostCallId::new("workout-export"),
payload: HostShareData::text(state.event_log_text()), payload: HostShareData::text(state.export_event_log()),
}; };
state.host_status = match manifest.validate_call(&browser_pwa_host_profile(), &call) { state.host_status = match manifest.validate_call(&browser_pwa_host_profile(), &call) {
Ok(()) => "Export requested through host share; waiting for host result.".into(), Ok(()) => "Share sheet requested; your local event log remains the source.".into(),
Err(error) => format!("Host export unavailable: {error}"), Err(error) => format!("Share unavailable: {error}. Export text stays below."),
}; };
effects(state, "Export request checked against host manifest") effects(state, "Share export prepared")
}) })
} }
@@ -475,7 +760,7 @@ pub fn record_share_result(app: AppState) -> impl IntoEffect {
completed: true, completed: true,
}, },
); );
effects(state, "Browser/PWA host result accepted by app code") effects(state, "Share result returned through app code")
}) })
} }
@@ -494,9 +779,7 @@ pub fn request_native_haptic(app: AppState) -> impl IntoEffect {
&native_shell_host_profile("ios-android-webview-workout"), &native_shell_host_profile("ios-android-webview-workout"),
&call, &call,
) { ) {
Ok(()) => { Ok(()) => "Native-shell haptic requested for the saved set.".into(),
"Native-shell haptic request accepted; waiting for host acknowledgment.".into()
}
Err(error) => format!("Native haptic unavailable: {error}"), Err(error) => format!("Native haptic unavailable: {error}"),
}; };
effects(state, "Native-shell host call checked against manifest") effects(state, "Native-shell host call checked against manifest")
@@ -522,17 +805,16 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
completed: true, .. completed: true, ..
} => { } => {
state.projection.exported = true; state.projection.exported = true;
state.host_status = state.host_status = "Shared. The replayable event log remains local truth.".into();
"Host share completed; event log remains replayable local truth.".into();
} }
HostEvent::ShareCompleted { HostEvent::ShareCompleted {
completed: false, .. completed: false, ..
} => { } => {
state.host_status = "Host share cancelled; local event log unchanged.".into(); state.host_status = "Share cancelled; local event log unchanged.".into();
} }
HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => { HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => {
state.host_status = state.host_status =
"Native-shell haptic acknowledged; workout state stayed app-owned.".into(); "Native haptic acknowledged; workout state stayed app-owned.".into();
} }
_ => { _ => {
state.host_status = "Host event ignored by app policy.".into(); state.host_status = "Host event ignored by app policy.".into();
@@ -562,38 +844,90 @@ mod tests {
app.with_workout(|state| ( app.with_workout(|state| (
state.commands.len(), state.commands.len(),
state.events.len(), state.events.len(),
state.projection.completed_sets_for_current state.projection.completed_sets_for_current,
state.projection.phase.clone(),
)), )),
(1, 1, 1) (1, 1, 1, WorkoutPhase::Resting)
); );
assert!(contains_payload_text( assert!(contains_payload_text(
&effects, &effects,
"completed Goblet squat set 1" "completed Goblet squat set 1"
)); ));
assert!(contains_payload_text( assert!(contains_payload_text(&effects, "Rest 90s"));
&effects, assert!(contains_payload_text(&effects, "Start Goblet squat set 2"));
"Next: Goblet squat set 2/3"
));
assert_eq!( assert_eq!(
app.with_workout(|state| (state.commands.len(), state.events.len())), app.with_workout(|state| (state.commands.len(), state.events.len())),
(1, 1) (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_eq!(
app.with_workout(|state| state.projection.phase.clone()),
WorkoutPhase::Finished
);
}
#[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] #[test]
fn export_payload_replays_into_projection_before_ui_effects() { fn export_payload_replays_into_projection_before_ui_effects() {
// req: local/001 req: local/003 req: local/004 // req: local/001 req: local/003 req: local/004
let app = AppState::demo(); let app = AppState::demo();
run(|()| complete_set(app.clone()), ()); run(|()| complete_set(app.clone()), ());
run(|()| complete_set(app.clone()), ());
run(|()| change_weight(app.clone(), 28.0), ()); run(|()| change_weight(app.clone(), 28.0), ());
let export = app.with_workout(WorkoutState::export_event_log); let export = app.with_workout(WorkoutState::export_event_log);
assert!(export.contains("set_completed\tGoblet squat\t1\t8\t24")); 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")); assert!(export.contains("weight_changed\tGoblet squat\t28"));
let replay = run(|()| replay_export(app.clone()), ()); let replay = run(|()| replay_export(app.clone()), ());
assert!(contains_payload_text( assert!(contains_payload_text(
&replay, &replay,
"Replayed 2 exported workout events" "Replayed 3 exported workout events"
)); ));
assert_eq!( assert_eq!(
app.with_workout(|state| { app.with_workout(|state| {
@@ -601,9 +935,10 @@ mod tests {
state.events.len(), state.events.len(),
state.projection.completed_sets_for_current, state.projection.completed_sets_for_current,
state.plan[0].kg, state.plan[0].kg,
state.projection.phase.clone(),
) )
}), }),
(2, 1, 28.0) (3, 1, 28.0, WorkoutPhase::Ready)
); );
} }
@@ -614,21 +949,18 @@ mod tests {
run(|()| complete_set(app.clone()), ()); run(|()| complete_set(app.clone()), ());
let export = run(|()| export_log(app.clone()), ()); let export = run(|()| export_log(app.clone()), ());
assert!(contains_payload_text( assert!(contains_payload_text(&export, "Share export prepared"));
&export, assert!(contains_payload_text(&export, "Share sheet requested"));
"Export request checked against host manifest"
));
assert!(contains_payload_text(&export, "waiting for host result"));
let shared = run(|()| record_share_result(app.clone()), ()); let shared = run(|()| record_share_result(app.clone()), ());
assert!(app.with_workout(|state| state.projection.exported)); assert!(app.with_workout(|state| state.projection.exported));
assert!(contains_payload_text( assert!(contains_payload_text(
&shared, &shared,
"Browser/PWA host result accepted by app code" "Share result returned through app code"
)); ));
assert!(contains_payload_text( assert!(contains_payload_text(
&shared, &shared,
"event log remains replayable local truth" "replayable event log remains local truth"
)); ));
} }
@@ -644,7 +976,7 @@ mod tests {
)); ));
assert!(contains_payload_text( assert!(contains_payload_text(
&request, &request,
"waiting for host acknowledgment" "Native-shell haptic requested"
)); ));
let ack = run(|()| record_native_haptic_ack(app.clone()), ()); let ack = run(|()| record_native_haptic_ack(app.clone()), ());
@@ -664,8 +996,9 @@ mod tests {
let html = render(&WorkoutState::demo()).to_string(); let html = render(&WorkoutState::demo()).to_string();
assert!(html.contains("Now-first Workout Copilot")); assert!(html.contains("Now-first Workout Copilot"));
assert!(html.contains("Complete set")); assert!(html.contains("Complete set"));
assert!(html.contains("Undo last action"));
assert!(html.contains("Share export")); assert!(html.contains("Share export"));
assert!(html.contains("Haptic")); assert!(html.contains("Host proof panel"));
assert!(!html.contains(&format!("<{}", "script"))); assert!(!html.contains(&format!("<{}", "script")));
assert!(!html.contains("querySelector")); assert!(!html.contains("querySelector"));
} }
+69 -25
View File
@@ -5,6 +5,7 @@
color-scheme: dark; color-scheme: dark;
--surface: #11130f; --surface: #11130f;
--surface-2: #171a14; --surface-2: #171a14;
--surface-3: #202518;
--ink: #f7f3e8; --ink: #f7f3e8;
--muted: #aaa38f; --muted: #aaa38f;
--line: #35392d; --line: #35392d;
@@ -46,7 +47,8 @@
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
button:focus-visible, button:focus-visible,
input:focus-visible { input:focus-visible,
summary:focus-visible {
outline: 3px solid var(--accent); outline: 3px solid var(--accent);
outline-offset: 3px; outline-offset: 3px;
} }
@@ -71,6 +73,14 @@
white-space: pre-wrap; white-space: pre-wrap;
font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
} }
summary {
cursor: pointer;
min-height: var(--tap);
display: flex;
align-items: center;
color: var(--muted);
font-weight: 850;
}
} }
@layer composition { @layer composition {
@@ -83,8 +93,8 @@
} }
.command-slate, .command-slate,
.action-rail, .action-rail,
.ledger, .finish-card,
.host-boundary { .ledger {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
background: rgb(23 26 20 / 0.92); background: rgb(23 26 20 / 0.92);
@@ -94,7 +104,7 @@
@layer blocks { @layer blocks {
.command-slate { .command-slate {
min-height: 42svh; min-height: 44svh;
padding: clamp(1.2rem, 8vw, 2.3rem); padding: clamp(1.2rem, 8vw, 2.3rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -112,13 +122,26 @@
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 900; font-weight: 900;
} }
.progress-pill {
width: max-content;
max-width: 100%;
border: 1px solid rgb(215 255 79 / 0.38);
border-radius: 999px;
padding: 0.45rem 0.75rem;
color: var(--accent);
background: rgb(215 255 79 / 0.08);
font-size: 0.82rem;
font-weight: 900;
}
.command-slate h1 { .command-slate h1 {
max-width: 12ch; max-width: 13ch;
font-size: clamp(2.3rem, 14vw, 4.4rem); font-size: clamp(2.2rem, 13vw, 4.4rem);
line-height: 0.92; line-height: 0.92;
letter-spacing: -0.08em; letter-spacing: -0.08em;
} }
.proofline { .session-status,
.recovery-status,
.finish-card p {
color: var(--muted); color: var(--muted);
font-weight: 750; font-weight: 750;
} }
@@ -128,7 +151,7 @@
z-index: 1; z-index: 1;
padding: 0.7rem; padding: 0.7rem;
display: grid; display: grid;
gap: 0.6rem; gap: 0.65rem;
} }
.primary-action { .primary-action {
color: var(--accent-ink); color: var(--accent-ink);
@@ -139,6 +162,11 @@
.quiet-action { .quiet-action {
color: var(--muted); color: var(--muted);
} }
.secondary-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.micro-form, .micro-form,
.voice-strip { .voice-strip {
display: grid; display: grid;
@@ -156,20 +184,47 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.ledger, .finish-card,
.host-boundary { .ledger {
padding: 1rem; padding: 1rem;
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
} }
.finish-card {
background:
linear-gradient(135deg, rgb(255 186 82 / 0.12), transparent 20rem),
var(--surface-2);
}
.finish-card h2 {
margin-block-start: 0.2rem;
font-size: 1.35rem;
line-height: 1;
letter-spacing: -0.05em;
}
.share-action {
color: var(--accent-ink);
background: var(--warn);
border-color: var(--warn);
}
.host-proof {
border-top: 1px dashed var(--line);
padding-block-start: 0.35rem;
}
.host-proof p {
margin-block-end: 0.7rem;
font-size: 0.92rem;
}
.host-actions {
display: grid;
gap: 0.55rem;
}
.section-heading { .section-heading {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.ledger h2, .ledger h2 {
.host-boundary h2 {
font-size: 1rem; font-size: 1rem;
letter-spacing: -0.03em; letter-spacing: -0.03em;
} }
@@ -184,17 +239,6 @@
padding: 0.75rem 0.9rem; padding: 0.75rem 0.9rem;
background: #0c0e0b; background: #0c0e0b;
} }
.host-boundary {
border-style: dashed;
}
.host-boundary p {
color: var(--muted);
}
.host-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
@media (min-width: 46rem) { @media (min-width: 46rem) {
.workout-app { .workout-app {
@@ -206,8 +250,8 @@
.action-rail { .action-rail {
grid-column: 1; grid-column: 1;
} }
.ledger, .finish-card,
.host-boundary { .ledger {
grid-column: 2; grid-column: 2;
} }
.action-rail { .action-rail {
+31 -17
View File
@@ -1,23 +1,48 @@
<main data-hemx-root="workout" class="workout-app"> <main data-hemx-root="workout" class="workout-app">
<section class="command-slate" aria-labelledby="next-action-heading"> <section class="command-slate" aria-labelledby="next-action-heading">
<p class="eyebrow">Now-first Workout Copilot</p> <p class="eyebrow">Now-first Workout Copilot</p>
<p class="progress-pill" data-hemx-slot="progress">{+ self.progress +}</p>
<h1 id="next-action-heading" data-hemx-slot="next_action">{+ self.next_action +}</h1> <h1 id="next-action-heading" data-hemx-slot="next_action">{+ self.next_action +}</h1>
<p class="proofline" data-hemx-slot="status">{+ self.status +}</p> <p class="session-status" data-hemx-slot="status">{+ self.status +}</p>
</section> </section>
<section class="action-rail" aria-label="Current workout action"> <section class="action-rail" aria-label="Current workout action">
<button class="primary-action" type="button" data-hemx-handle="complete_set">Complete set</button> <button class="primary-action" type="button" data-hemx-handle="complete_set">
<span data-hemx-slot="primary_action">{+ self.primary_action +}</span>
</button>
<p class="recovery-status" data-hemx-slot="recovery_status">{+ self.recovery_status +}</p>
<div class="secondary-actions">
<button class="quiet-action" type="button" data-hemx-handle="undo_last_action">Undo last action</button>
<button class="quiet-action" type="button" data-hemx-handle="skip_exercise">Skip exercise</button>
</div>
<form class="micro-form" data-hemx-handle="change_weight" method="post"> <form class="micro-form" data-hemx-handle="change_weight" method="post">
<label>Weight <input name="kg" value="52.5" inputmode="decimal" aria-label="Weight in kilograms"></label> <label>Adjust weight <input name="kg" value="52.5" inputmode="decimal" aria-label="Weight in kilograms"></label>
<button type="submit">Update</button> <button type="submit">Save</button>
</form> </form>
<button class="quiet-action" type="button" data-hemx-handle="skip_exercise">Skip</button>
<form class="voice-strip" data-hemx-handle="record_note" method="post"> <form class="voice-strip" data-hemx-handle="record_note" method="post">
<label>Dictate note <input name="text" value="left shoulder felt tight"></label> <label>Session note <input name="text" value="left shoulder felt tight"></label>
<button type="submit">Record</button> <button type="submit">Record</button>
</form> </form>
</section> </section>
<section class="finish-card" aria-labelledby="finish-heading">
<div>
<p class="eyebrow">Finish & recover</p>
<h2 id="finish-heading">Your session is a replayable local log.</h2>
</div>
<p data-hemx-slot="host_status">{+ self.host_status +}</p>
<button class="share-action" type="button" data-hemx-handle="export_log">Share export</button>
<details class="host-proof">
<summary>Host proof panel</summary>
<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="request_native_haptic">Request native haptic</button>
<button type="button" data-hemx-handle="record_native_haptic_ack">Mark haptic acknowledged</button>
</div>
</details>
</section>
<section class="ledger" aria-labelledby="event-log-heading"> <section class="ledger" aria-labelledby="event-log-heading">
<div class="section-heading"> <div class="section-heading">
<h2 id="event-log-heading">Private event log</h2> <h2 id="event-log-heading">Private event log</h2>
@@ -27,15 +52,4 @@
<h3>Export payload</h3> <h3>Export payload</h3>
<pre data-hemx-slot="export_payload">{+ self.export_payload +}</pre> <pre data-hemx-slot="export_payload">{+ self.export_payload +}</pre>
</section> </section>
<section class="host-boundary" aria-labelledby="host-heading">
<h2 id="host-heading">Host boundary</h2>
<p data-hemx-slot="host_status">{+ self.host_status +}</p>
<div class="host-actions">
<button type="button" data-hemx-handle="export_log">Share export</button>
<button type="button" data-hemx-handle="record_share_result">Share done</button>
<button type="button" data-hemx-handle="request_native_haptic">Haptic</button>
<button type="button" data-hemx-handle="record_native_haptic_ack">Haptic done</button>
</div>
</section>
</main> </main>
+6 -1
View File
@@ -52,6 +52,8 @@ fn workout_is_e2e_working_over_http() {
assert_eq!(home.status, 200); assert_eq!(home.status, 200);
assert!(home.header("content-type").contains("text/html")); assert!(home.header("content-type").contains("text/html"));
assert!(home.text().contains("Now-first Workout Copilot")); assert!(home.text().contains("Now-first Workout Copilot"));
assert!(home.text().contains("Finish & recover"));
assert!(home.text().contains("Undo last action"));
assert!(home.text().contains("Private event log")); assert!(home.text().contains("Private event log"));
assert!(home.text().contains("Replay export")); assert!(home.text().contains("Replay export"));
assert!(home assert!(home
@@ -65,6 +67,7 @@ fn workout_is_e2e_working_over_http() {
assert_eq!(css.status, 200); assert_eq!(css.status, 200);
assert!(css.header("content-type").contains("text/css")); assert!(css.header("content-type").contains("text/css"));
assert!(css.text().contains(".command-slate")); assert!(css.text().contains(".command-slate"));
assert!(css.text().contains(".finish-card"));
assert!(css.text().contains("min-height: var(--tap)")); assert!(css.text().contains("min-height: var(--tap)"));
let runtime = get(&server, runtime_js_path()); let runtime = get(&server, runtime_js_path());
@@ -80,7 +83,9 @@ fn workout_is_e2e_working_over_http() {
assert_effect_response(&completed); assert_effect_response(&completed);
let completed_batch = completed.effects(); let completed_batch = completed.effects();
assert_payload_contains(&completed_batch, "completed Goblet squat set 1"); assert_payload_contains(&completed_batch, "completed Goblet squat set 1");
assert_payload_contains(&completed_batch, "Next: Goblet squat set 2/3"); assert_payload_contains(&completed_batch, "Rest 90s · next: Goblet squat set 2");
assert_payload_contains(&completed_batch, "Start Goblet squat set 2");
assert_payload_contains(&completed_batch, "1 of 5 sets · 20% complete");
let replayed = post( let replayed = post(
&server, &server,