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
This commit is contained in:
slhx agent
2026-06-11 23:29:09 +02:00
parent c146e9b932
commit 900ae9f51f
4 changed files with 72 additions and 8 deletions
+3 -3
View File
@@ -2,9 +2,9 @@
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
complete useful session: next action shown, set completed, rest/next state complete useful session: next action shown, set completed, rest/next state
entered, progress updated, mistakes undone, workout finished, and invalid input, entered, progress updated, mistakes undone, workout finished, final export shared
replay failure, or host denial recovered without storing DOM patches or UI update or replayed, and invalid input, replay failure, or host denial recovered without
payloads as truth. storing DOM patches or UI update payloads as truth.
req: examples/001 req: local/001 req: local/003 req: local/004 req: examples/001 req: local/001 req: local/003 req: local/004
## Run ## Run
+41 -1
View File
@@ -580,6 +580,9 @@ pub struct Workout {
pub status: String, pub status: String,
pub progress: String, pub progress: String,
pub recovery_status: String, pub recovery_status: String,
pub finish_title: String,
pub finish_copy: String,
pub share_action: 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,
@@ -600,12 +603,43 @@ 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(); 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 { Workout {
next_action: state.projection.next_action.clone(), next_action: state.projection.next_action.clone(),
primary_action: state.projection.primary_action.clone(), primary_action: state.projection.primary_action.clone(),
status: state.projection.progress.clone(), status: state.projection.progress.clone(),
progress: state.projection.progress.clone(), progress: state.projection.progress.clone(),
recovery_status: state.recovery_text(), recovery_status: state.recovery_text(),
finish_title,
finish_copy,
share_action,
event_log: state.event_log_text(), event_log: state.event_log_text(),
export_payload: if export.is_empty() { export_payload: if export.is_empty() {
"Complete a set to create a replayable export.".into() "Complete a set to create a replayable export.".into()
@@ -663,6 +697,9 @@ fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect {
ui::workout::status.text(status.into()), ui::workout::status.text(status.into()),
ui::workout::progress.text(&view.progress), ui::workout::progress.text(&view.progress),
ui::workout::recovery_status.text(&view.recovery_status), 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::event_log.text(&view.event_log),
ui::workout::export_payload.text(&view.export_payload), ui::workout::export_payload.text(&view.export_payload),
ui::workout::host_status.text(&view.host_status), ui::workout::host_status.text(&view.host_status),
@@ -985,6 +1022,8 @@ mod tests {
&finished, &finished,
"finished workout: 5/5 sets complete" "finished workout: 5/5 sets complete"
)); ));
assert!(contains_payload_text(&finished, "Workout saved"));
assert!(contains_payload_text(&finished, "Share final export"));
assert_eq!( assert_eq!(
app.with_workout(|state| state.projection.phase.clone()), app.with_workout(|state| state.projection.phase.clone()),
WorkoutPhase::Finished WorkoutPhase::Finished
@@ -1144,8 +1183,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("Finish unlocks as you train"));
assert!(html.contains("Export after first set"));
assert!(html.contains("Undo last action")); assert!(html.contains("Undo last action"));
assert!(html.contains("Share export"));
assert!(html.contains("Host proof panel")); assert!(html.contains("Host proof panel"));
assert!(html.contains("Simulate share denied")); assert!(html.contains("Simulate share denied"));
assert!(html.contains("Simulate replay failure")); assert!(html.contains("Simulate replay failure"));
+5 -2
View File
@@ -28,10 +28,13 @@
<section class="finish-card" aria-labelledby="finish-heading"> <section class="finish-card" aria-labelledby="finish-heading">
<div> <div>
<p class="eyebrow">Finish & recover</p> <p class="eyebrow">Finish & recover</p>
<h2 id="finish-heading">Your session is a replayable local log.</h2> <h2 id="finish-heading" data-hemx-slot="finish_title">{+ self.finish_title +}</h2>
</div> </div>
<p data-hemx-slot="finish_copy">{+ self.finish_copy +}</p>
<p data-hemx-slot="host_status">{+ self.host_status +}</p> <p data-hemx-slot="host_status">{+ self.host_status +}</p>
<button class="share-action" type="button" data-hemx-handle="export_log">Share export</button> <button class="share-action" type="button" data-hemx-handle="export_log">
<span data-hemx-slot="share_action">{+ self.share_action +}</span>
</button>
<details class="host-proof"> <details class="host-proof">
<summary>Host proof panel</summary> <summary>Host proof panel</summary>
<p>Development-only host results return through app code before UI effects.</p> <p>Development-only host results return through app code before UI effects.</p>
+23 -2
View File
@@ -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, "Start Goblet squat set 2");
assert_payload_contains(&completed_batch, "1 of 5 sets · 20% complete"); 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( let replayed = post(
&server, &server,
"/", "/",
@@ -94,10 +115,10 @@ fn workout_is_e2e_working_over_http() {
); );
assert_effect_response(&replayed); assert_effect_response(&replayed);
let replayed_batch = replayed.effects(); 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( assert_payload_contains(
&replayed_batch, &replayed_batch,
"Replayed 1 exported events into a fresh projection", "Replayed 10 exported events into a fresh projection",
); );
} }