diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 88d5137..09e42ed 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -795,7 +795,7 @@ what a valid business email is. ## examples ### req: examples/001 -001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus 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 002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions. diff --git a/examples/workout/DESIGN.md b/examples/workout/DESIGN.md index 01129b8..6adeccf 100644 --- a/examples/workout/DESIGN.md +++ b/examples/workout/DESIGN.md @@ -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. diff --git a/examples/workout/README.md b/examples/workout/README.md index df16703..bbe27ae 100644 --- a/examples/workout/README.md +++ b/examples/workout/README.md @@ -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 diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index a68c3b0..8e594e1 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -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 { // req: local/001 req: local/004 let event = self.validate(&command)?; @@ -131,52 +144,104 @@ impl WorkoutState { } fn validate(&self, command: &WorkoutCommand) -> Option { - 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 { + // 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 { + match self.projection.phase { + WorkoutPhase::Finished | WorkoutPhase::ReadyToFinish => None, + WorkoutPhase::Resting + if self.current_exercise().is_some_and(|exercise| { + self.projection.completed_sets_for_current >= exercise.target_sets + }) => + { + (self.projection.current_exercise + 1 < self.plan.len()) + .then_some(self.projection.current_exercise + 1) + } + _ => self + .current_exercise() + .map(|_| self.projection.current_exercise), + } + } + + fn exercise_index(&self, name: &str) -> Option { + self.plan.iter().position(|exercise| exercise.name == name) + } + + fn 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 { + 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) -> 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) -> 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")); } diff --git a/examples/workout/templates/workout.css b/examples/workout/templates/workout.css index f486aa4..f13135f 100644 --- a/examples/workout/templates/workout.css +++ b/examples/workout/templates/workout.css @@ -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 { diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml index 8add88a..e85758b 100644 --- a/examples/workout/templates/workout.heml +++ b/examples/workout/templates/workout.heml @@ -1,23 +1,48 @@

Now-first Workout Copilot

+

{+ self.progress +}

{+ self.next_action +}

-

{+ self.status +}

+

{+ self.status +}

- + +

{+ self.recovery_status +}

+
+ + +
- - + +
-
- +
+
+
+

Finish & recover

+

Your session is a replayable local log.

+
+

{+ self.host_status +}

+ +
+ Host proof panel +

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

+
+ + + +
+
+
+

Private event log

@@ -27,15 +52,4 @@

Export payload

{+ self.export_payload +}
- -
-

Host boundary

-

{+ self.host_status +}

-
- - - - -
-
diff --git a/examples/workout/tests/e2e.rs b/examples/workout/tests/e2e.rs index 8f62c3c..7e8debc 100644 --- a/examples/workout/tests/e2e.rs +++ b/examples/workout/tests/e2e.rs @@ -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,