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
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
+41 -1
View File
@@ -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<String>) -> 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"));
+5 -2
View File
@@ -28,10 +28,13 @@
<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>
<h2 id="finish-heading" data-hemx-slot="finish_title">{+ self.finish_title +}</h2>
</div>
<p data-hemx-slot="finish_copy">{+ self.finish_copy +}</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">
<summary>Host proof panel</summary>
<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, "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",
);
}