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:
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user