From 900ae9f51fd7d85e38d9c1554a8c56eae325b356 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 11 Jun 2026 23:29:09 +0200 Subject: [PATCH] feat(workout): make finish export a product state Promote the finish/export card from static copy into session-aware product state and extend the HTTP exemplar through full finish, export, and replay. req: examples/001 req: local/001 req: local/003 req: local/004 --- examples/workout/README.md | 6 ++-- examples/workout/src/lib.rs | 42 ++++++++++++++++++++++++- examples/workout/templates/workout.heml | 7 +++-- examples/workout/tests/e2e.rs | 25 +++++++++++++-- 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/examples/workout/README.md b/examples/workout/README.md index 94e7e9e..76150f2 100644 --- a/examples/workout/README.md +++ b/examples/workout/README.md @@ -2,9 +2,9 @@ This is the phone-first local-first product exemplar for hemx. It shows a complete useful session: next action shown, set completed, rest/next state -entered, progress updated, mistakes undone, workout finished, and invalid input, -replay failure, or host denial recovered without storing DOM patches or UI update -payloads as truth. +entered, progress updated, mistakes undone, workout finished, final export shared +or replayed, and invalid input, replay failure, or host denial recovered without +storing DOM patches or UI update payloads as truth. req: examples/001 req: local/001 req: local/003 req: local/004 ## Run diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index ba34122..5f21d2c 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -580,6 +580,9 @@ pub struct Workout { pub status: String, pub progress: String, pub recovery_status: String, + pub finish_title: String, + pub finish_copy: String, + pub share_action: String, pub event_log: String, pub export_payload: String, pub host_status: String, @@ -600,12 +603,43 @@ pub fn page(runtime_src: &'static str, state: &WorkoutState) -> Html { pub fn view(state: &WorkoutState) -> Workout { let export = state.export_event_log(); + let has_events = !state.events.is_empty(); + let (finish_title, finish_copy, share_action) = match state.projection.phase { + WorkoutPhase::Ready | WorkoutPhase::Resting if !has_events => ( + "Finish unlocks as you train".into(), + "Complete a set to create a replayable local export.".into(), + "Export after first set".into(), + ), + WorkoutPhase::Ready | WorkoutPhase::Resting => ( + "Session log is ready".into(), + "Your progress is already replayable; finish all sets for the final export moment.".into(), + "Share current log".into(), + ), + WorkoutPhase::ReadyToFinish => ( + "All sets complete".into(), + "Finish to save the workout summary, then share or replay the exact local event log.".into(), + "Finish, then share".into(), + ), + WorkoutPhase::Finished if state.projection.exported => ( + "Export shared".into(), + "The shared text is still just a copy; this local event log remains the source.".into(), + "Share again".into(), + ), + WorkoutPhase::Finished => ( + "Workout saved".into(), + "Share the final event log, or replay it locally to prove recovery without hidden browser state.".into(), + "Share final export".into(), + ), + }; Workout { next_action: state.projection.next_action.clone(), primary_action: state.projection.primary_action.clone(), status: state.projection.progress.clone(), progress: state.projection.progress.clone(), recovery_status: state.recovery_text(), + finish_title, + finish_copy, + share_action, event_log: state.event_log_text(), export_payload: if export.is_empty() { "Complete a set to create a replayable export.".into() @@ -663,6 +697,9 @@ fn effects(state: &WorkoutState, status: impl Into) -> impl IntoEffect { ui::workout::status.text(status.into()), ui::workout::progress.text(&view.progress), ui::workout::recovery_status.text(&view.recovery_status), + ui::workout::finish_title.text(&view.finish_title), + ui::workout::finish_copy.text(&view.finish_copy), + ui::workout::share_action.text(&view.share_action), ui::workout::event_log.text(&view.event_log), ui::workout::export_payload.text(&view.export_payload), ui::workout::host_status.text(&view.host_status), @@ -985,6 +1022,8 @@ mod tests { &finished, "finished workout: 5/5 sets complete" )); + assert!(contains_payload_text(&finished, "Workout saved")); + assert!(contains_payload_text(&finished, "Share final export")); assert_eq!( app.with_workout(|state| state.projection.phase.clone()), WorkoutPhase::Finished @@ -1144,8 +1183,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("Finish unlocks as you train")); + assert!(html.contains("Export after first set")); assert!(html.contains("Undo last action")); - assert!(html.contains("Share export")); assert!(html.contains("Host proof panel")); assert!(html.contains("Simulate share denied")); assert!(html.contains("Simulate replay failure")); diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml index 9944015..597cd90 100644 --- a/examples/workout/templates/workout.heml +++ b/examples/workout/templates/workout.heml @@ -28,10 +28,13 @@

Finish & recover

-

Your session is a replayable local log.

+

{+ self.finish_title +}

+

{+ self.finish_copy +}

{+ self.host_status +}

- +
Host proof panel

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

diff --git a/examples/workout/tests/e2e.rs b/examples/workout/tests/e2e.rs index 7e8debc..abf762a 100644 --- a/examples/workout/tests/e2e.rs +++ b/examples/workout/tests/e2e.rs @@ -87,6 +87,27 @@ fn workout_is_e2e_working_over_http() { assert_payload_contains(&completed_batch, "Start Goblet squat set 2"); assert_payload_contains(&completed_batch, "1 of 5 sets ยท 20% complete"); + let complete_body = handle_body_from_html(&home.text(), "Complete set"); + let mut final_step = completed; + for _ in 0..9 { + final_step = post(&server, "/", &complete_body); + assert_effect_response(&final_step); + } + let final_batch = final_step.effects(); + assert_payload_contains(&final_batch, "finished workout: 5/5 sets complete"); + assert_payload_contains(&final_batch, "Workout saved"); + assert_payload_contains(&final_batch, "Share final export"); + + let exported = post( + &server, + "/", + &handle_body_from_html(&home.text(), "Export after first set"), + ); + assert_effect_response(&exported); + let exported_batch = exported.effects(); + assert_payload_contains(&exported_batch, "Share export prepared"); + assert_payload_contains(&exported_batch, "Share sheet requested"); + let replayed = post( &server, "/", @@ -94,10 +115,10 @@ fn workout_is_e2e_working_over_http() { ); assert_effect_response(&replayed); let replayed_batch = replayed.effects(); - assert_payload_contains(&replayed_batch, "Replayed 1 exported workout events"); + assert_payload_contains(&replayed_batch, "Replayed 10 exported workout events"); assert_payload_contains( &replayed_batch, - "Replayed 1 exported events into a fresh projection", + "Replayed 10 exported events into a fresh projection", ); }