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:
@@ -1,12 +1,13 @@
|
||||
# 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:
|
||||
- First viewport shows one obvious action path before logs or host details.
|
||||
- Controls stay large enough for sweaty one-handed use; forms remain secondary to tap actions.
|
||||
- Rest/next, progress, undo recovery, and finish/export feel like session states, not framework demos.
|
||||
- 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:
|
||||
- Dashboard/KPI cards, fake charts, bottom-nav app chrome, glass/gradient hero filler, and hidden client state.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Now-first Workout Copilot example
|
||||
|
||||
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
|
||||
recorded, a domain event projected, hemx UI updated, and the result exported or
|
||||
replayed without storing DOM patches or UI update payloads as truth. req: examples/001
|
||||
req: local/001 req: local/003 req: local/004
|
||||
complete useful session: next action shown, set completed, rest/next state
|
||||
entered, progress updated, mistakes undone, workout finished, and the result
|
||||
exported or replayed without storing DOM patches or UI update payloads as truth.
|
||||
req: examples/001 req: local/001 req: local/003 req: local/004
|
||||
|
||||
## 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
|
||||
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:
|
||||
|
||||
@@ -57,7 +59,8 @@ ship. req: axum_integration/005 req: examples/005
|
||||
## Boundaries proven
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
+442
-109
@@ -23,9 +23,12 @@ pub struct ExercisePlan {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum WorkoutCommand {
|
||||
CompleteSet,
|
||||
StartNextSet,
|
||||
FinishWorkout,
|
||||
ChangeWeight { kg: f32 },
|
||||
SkipExercise,
|
||||
RecordNote { text: String },
|
||||
UndoLastAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -36,6 +39,10 @@ pub enum WorkoutEvent {
|
||||
reps: u8,
|
||||
kg: f32,
|
||||
},
|
||||
RestFinished {
|
||||
exercise: String,
|
||||
set: u8,
|
||||
},
|
||||
WeightChanged {
|
||||
exercise: String,
|
||||
kg: f32,
|
||||
@@ -46,13 +53,29 @@ pub enum WorkoutEvent {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -92,35 +115,25 @@ impl AppState {
|
||||
impl WorkoutState {
|
||||
pub fn demo() -> Self {
|
||||
let mut state = Self {
|
||||
plan: 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,
|
||||
},
|
||||
],
|
||||
plan: demo_plan(),
|
||||
commands: Vec::new(),
|
||||
events: Vec::new(),
|
||||
projection: WorkoutProjection {
|
||||
current_exercise: 0,
|
||||
completed_sets_for_current: 0,
|
||||
next_action: String::new(),
|
||||
exported: false,
|
||||
},
|
||||
projection: WorkoutProjection::start(),
|
||||
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
|
||||
}
|
||||
|
||||
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)?;
|
||||
@@ -131,52 +144,104 @@ impl WorkoutState {
|
||||
}
|
||||
|
||||
fn validate(&self, command: &WorkoutCommand) -> Option<WorkoutEvent> {
|
||||
let exercise = self.plan.get(self.projection.current_exercise)?;
|
||||
match command {
|
||||
WorkoutCommand::CompleteSet => Some(WorkoutEvent::SetCompleted {
|
||||
exercise: exercise.name.into(),
|
||||
set: self.projection.completed_sets_for_current + 1,
|
||||
reps: exercise.reps,
|
||||
kg: exercise.kg,
|
||||
}),
|
||||
WorkoutCommand::ChangeWeight { kg } => Some(WorkoutEvent::WeightChanged {
|
||||
exercise: exercise.name.into(),
|
||||
kg: *kg,
|
||||
}),
|
||||
WorkoutCommand::SkipExercise => Some(WorkoutEvent::ExerciseSkipped {
|
||||
exercise: exercise.name.into(),
|
||||
}),
|
||||
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::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(),
|
||||
})
|
||||
}
|
||||
WorkoutCommand::RecordNote { .. } => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn project(&mut self, event: &WorkoutEvent) {
|
||||
// req: local/001 req: local/004
|
||||
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;
|
||||
if let Some(exercise) = self.plan.get(self.projection.current_exercise) {
|
||||
if *set >= exercise.target_sets {
|
||||
self.projection.current_exercise += 1;
|
||||
self.projection.completed_sets_for_current = 0;
|
||||
}
|
||||
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 } => {
|
||||
if let Some(index) = self.exercise_index(exercise) {
|
||||
self.plan[index].kg = *kg;
|
||||
}
|
||||
}
|
||||
WorkoutEvent::WeightChanged { kg, .. } => {
|
||||
if let Some(exercise) = self.plan.get_mut(self.projection.current_exercise) {
|
||||
exercise.kg = *kg;
|
||||
}
|
||||
}
|
||||
WorkoutEvent::ExerciseSkipped { .. } => {
|
||||
self.projection.current_exercise += 1;
|
||||
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());
|
||||
@@ -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) {
|
||||
self.projection.next_action = self
|
||||
.plan
|
||||
.get(self.projection.current_exercise)
|
||||
.map(|exercise| {
|
||||
format!(
|
||||
"Next: {} set {}/{} · {} reps · {} kg",
|
||||
exercise.name,
|
||||
self.projection.completed_sets_for_current + 1,
|
||||
exercise.target_sets,
|
||||
exercise.reps,
|
||||
exercise.kg
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "Workout complete · export or replay the event log".into());
|
||||
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 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 "[]".into();
|
||||
return "No session events yet.".into();
|
||||
}
|
||||
self.events
|
||||
.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 {
|
||||
value.replace(['\t', '\n', '\r'], " ")
|
||||
}
|
||||
@@ -258,9 +470,14 @@ impl WorkoutEvent {
|
||||
} => {
|
||||
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::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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +493,9 @@ impl WorkoutEvent {
|
||||
"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))
|
||||
}
|
||||
@@ -283,6 +503,10 @@ impl WorkoutEvent {
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +520,10 @@ impl WorkoutEvent {
|
||||
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()?,
|
||||
@@ -306,6 +534,10 @@ impl WorkoutEvent {
|
||||
["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,
|
||||
}
|
||||
}
|
||||
@@ -314,7 +546,10 @@ impl WorkoutEvent {
|
||||
#[derive(Hemplate)]
|
||||
pub struct Workout {
|
||||
pub next_action: String,
|
||||
pub primary_action: String,
|
||||
pub status: String,
|
||||
pub progress: String,
|
||||
pub recovery_status: String,
|
||||
pub event_log: String,
|
||||
pub export_payload: 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 {
|
||||
let export = state.export_event_log();
|
||||
Workout {
|
||||
next_action: state.projection.next_action.clone(),
|
||||
status: format!(
|
||||
"{} local commands · {} domain events · projection is the UI source",
|
||||
state.commands.len(),
|
||||
state.events.len()
|
||||
),
|
||||
primary_action: state.projection.primary_action.clone(),
|
||||
status: state.projection.progress.clone(),
|
||||
progress: state.projection.progress.clone(),
|
||||
recovery_status: state
|
||||
.activity
|
||||
.front()
|
||||
.map(|last| format!("Last action: {last}. Undo is available."))
|
||||
.unwrap_or_else(|| "Undo appears here after your first session event.".into()),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -370,31 +613,47 @@ pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry {
|
||||
.on(ui::workout::change_weight, change_weight_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::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()
|
||||
}
|
||||
|
||||
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(&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::event_log.text(state.event_log_text()),
|
||||
ui::workout::export_payload.text(state.export_event_log()),
|
||||
ui::workout::host_status.text(&state.host_status),
|
||||
ui::workout::progress.text(&view.progress),
|
||||
ui::workout::recovery_status.text(&view.recovery_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 {
|
||||
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(
|
||||
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 });
|
||||
effects(
|
||||
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);
|
||||
effects(
|
||||
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() });
|
||||
effects(
|
||||
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 {
|
||||
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.event_log_text()),
|
||||
payload: HostShareData::text(state.export_event_log()),
|
||||
};
|
||||
state.host_status = match manifest.validate_call(&browser_pwa_host_profile(), &call) {
|
||||
Ok(()) => "Export requested through host share; waiting for host result.".into(),
|
||||
Err(error) => format!("Host export unavailable: {error}"),
|
||||
Ok(()) => "Share sheet requested; your local event log remains the source.".into(),
|
||||
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,
|
||||
},
|
||||
);
|
||||
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"),
|
||||
&call,
|
||||
) {
|
||||
Ok(()) => {
|
||||
"Native-shell haptic request accepted; waiting for host acknowledgment.".into()
|
||||
}
|
||||
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")
|
||||
@@ -522,17 +805,16 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
|
||||
completed: true, ..
|
||||
} => {
|
||||
state.projection.exported = true;
|
||||
state.host_status =
|
||||
"Host share completed; event log remains replayable local truth.".into();
|
||||
state.host_status = "Shared. The replayable event log remains local truth.".into();
|
||||
}
|
||||
HostEvent::ShareCompleted {
|
||||
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" => {
|
||||
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();
|
||||
@@ -562,38 +844,90 @@ mod tests {
|
||||
app.with_workout(|state| (
|
||||
state.commands.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(
|
||||
&effects,
|
||||
"completed Goblet squat set 1"
|
||||
));
|
||||
assert!(contains_payload_text(
|
||||
&effects,
|
||||
"Next: Goblet squat set 2/3"
|
||||
));
|
||||
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_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]
|
||||
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 2 exported workout events"
|
||||
"Replayed 3 exported workout events"
|
||||
));
|
||||
assert_eq!(
|
||||
app.with_workout(|state| {
|
||||
@@ -601,9 +935,10 @@ mod tests {
|
||||
state.events.len(),
|
||||
state.projection.completed_sets_for_current,
|
||||
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()), ());
|
||||
|
||||
let export = run(|()| export_log(app.clone()), ());
|
||||
assert!(contains_payload_text(
|
||||
&export,
|
||||
"Export request checked against host manifest"
|
||||
));
|
||||
assert!(contains_payload_text(&export, "waiting for host result"));
|
||||
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,
|
||||
"Browser/PWA host result accepted by app code"
|
||||
"Share result returned through app code"
|
||||
));
|
||||
assert!(contains_payload_text(
|
||||
&shared,
|
||||
"event log remains replayable local truth"
|
||||
"replayable event log remains local truth"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -644,7 +976,7 @@ mod tests {
|
||||
));
|
||||
assert!(contains_payload_text(
|
||||
&request,
|
||||
"waiting for host acknowledgment"
|
||||
"Native-shell haptic requested"
|
||||
));
|
||||
|
||||
let ack = run(|()| record_native_haptic_ack(app.clone()), ());
|
||||
@@ -664,8 +996,9 @@ mod tests {
|
||||
let html = render(&WorkoutState::demo()).to_string();
|
||||
assert!(html.contains("Now-first Workout Copilot"));
|
||||
assert!(html.contains("Complete set"));
|
||||
assert!(html.contains("Undo last action"));
|
||||
assert!(html.contains("Share export"));
|
||||
assert!(html.contains("Haptic"));
|
||||
assert!(html.contains("Host proof panel"));
|
||||
assert!(!html.contains(&format!("<{}", "script")));
|
||||
assert!(!html.contains("querySelector"));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
color-scheme: dark;
|
||||
--surface: #11130f;
|
||||
--surface-2: #171a14;
|
||||
--surface-3: #202518;
|
||||
--ink: #f7f3e8;
|
||||
--muted: #aaa38f;
|
||||
--line: #35392d;
|
||||
@@ -46,7 +47,8 @@
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
input:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@@ -71,6 +73,14 @@
|
||||
white-space: pre-wrap;
|
||||
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 {
|
||||
@@ -83,8 +93,8 @@
|
||||
}
|
||||
.command-slate,
|
||||
.action-rail,
|
||||
.ledger,
|
||||
.host-boundary {
|
||||
.finish-card,
|
||||
.ledger {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: rgb(23 26 20 / 0.92);
|
||||
@@ -94,7 +104,7 @@
|
||||
|
||||
@layer blocks {
|
||||
.command-slate {
|
||||
min-height: 42svh;
|
||||
min-height: 44svh;
|
||||
padding: clamp(1.2rem, 8vw, 2.3rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -112,13 +122,26 @@
|
||||
font-size: 0.78rem;
|
||||
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 {
|
||||
max-width: 12ch;
|
||||
font-size: clamp(2.3rem, 14vw, 4.4rem);
|
||||
max-width: 13ch;
|
||||
font-size: clamp(2.2rem, 13vw, 4.4rem);
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
.proofline {
|
||||
.session-status,
|
||||
.recovery-status,
|
||||
.finish-card p {
|
||||
color: var(--muted);
|
||||
font-weight: 750;
|
||||
}
|
||||
@@ -128,7 +151,7 @@
|
||||
z-index: 1;
|
||||
padding: 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.primary-action {
|
||||
color: var(--accent-ink);
|
||||
@@ -139,6 +162,11 @@
|
||||
.quiet-action {
|
||||
color: var(--muted);
|
||||
}
|
||||
.secondary-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.micro-form,
|
||||
.voice-strip {
|
||||
display: grid;
|
||||
@@ -156,20 +184,47 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.ledger,
|
||||
.host-boundary {
|
||||
.finish-card,
|
||||
.ledger {
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ledger h2,
|
||||
.host-boundary h2 {
|
||||
.ledger h2 {
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
@@ -184,17 +239,6 @@
|
||||
padding: 0.75rem 0.9rem;
|
||||
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) {
|
||||
.workout-app {
|
||||
@@ -206,8 +250,8 @@
|
||||
.action-rail {
|
||||
grid-column: 1;
|
||||
}
|
||||
.ledger,
|
||||
.host-boundary {
|
||||
.finish-card,
|
||||
.ledger {
|
||||
grid-column: 2;
|
||||
}
|
||||
.action-rail {
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
<main data-hemx-root="workout" class="workout-app">
|
||||
<section class="command-slate" aria-labelledby="next-action-heading">
|
||||
<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>
|
||||
<p class="proofline" data-hemx-slot="status">{+ self.status +}</p>
|
||||
<p class="session-status" data-hemx-slot="status">{+ self.status +}</p>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<label>Weight <input name="kg" value="52.5" inputmode="decimal" aria-label="Weight in kilograms"></label>
|
||||
<button type="submit">Update</button>
|
||||
<label>Adjust weight <input name="kg" value="52.5" inputmode="decimal" aria-label="Weight in kilograms"></label>
|
||||
<button type="submit">Save</button>
|
||||
</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">
|
||||
<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>
|
||||
</form>
|
||||
</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">
|
||||
<div class="section-heading">
|
||||
<h2 id="event-log-heading">Private event log</h2>
|
||||
@@ -27,15 +52,4 @@
|
||||
<h3>Export payload</h3>
|
||||
<pre data-hemx-slot="export_payload">{+ self.export_payload +}</pre>
|
||||
</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>
|
||||
|
||||
@@ -52,6 +52,8 @@ fn workout_is_e2e_working_over_http() {
|
||||
assert_eq!(home.status, 200);
|
||||
assert!(home.header("content-type").contains("text/html"));
|
||||
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("Replay export"));
|
||||
assert!(home
|
||||
@@ -65,6 +67,7 @@ fn workout_is_e2e_working_over_http() {
|
||||
assert_eq!(css.status, 200);
|
||||
assert!(css.header("content-type").contains("text/css"));
|
||||
assert!(css.text().contains(".command-slate"));
|
||||
assert!(css.text().contains(".finish-card"));
|
||||
assert!(css.text().contains("min-height: var(--tap)"));
|
||||
|
||||
let runtime = get(&server, runtime_js_path());
|
||||
@@ -80,7 +83,9 @@ fn workout_is_e2e_working_over_http() {
|
||||
assert_effect_response(&completed);
|
||||
let completed_batch = completed.effects();
|
||||
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(
|
||||
&server,
|
||||
|
||||
Reference in New Issue
Block a user