From 19bf47ec720316b789a9e1e977c0f580737d7b33 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 11 Jun 2026 20:38:06 +0200 Subject: [PATCH] feat(examples): add workout copilot exemplar Add a runnable phone-first workout example that keeps local workout truth as commands, domain events, and projections, routes export through the host capability boundary, and documents how to open it in the browser. req: examples/001 req: local/001 req: local/003 req: local/004 req: host/001 req: host/005 --- Cargo.lock | 14 + Cargo.toml | 2 +- README.md | 4 + REQUIREMENTS.md | 2 +- examples/workout/Cargo.toml | 26 ++ examples/workout/build.rs | 3 + examples/workout/src/lib.rs | 437 ++++++++++++++++++++++++ examples/workout/src/main.rs | 86 +++++ examples/workout/templates/workout.heml | 32 ++ 9 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 examples/workout/Cargo.toml create mode 100644 examples/workout/build.rs create mode 100644 examples/workout/src/lib.rs create mode 100644 examples/workout/src/main.rs create mode 100644 examples/workout/templates/workout.heml diff --git a/Cargo.lock b/Cargo.lock index fa5d975..c3d6983 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -602,6 +602,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "hemx-workout-example" +version = "0.1.0" +dependencies = [ + "axum", + "hemplate", + "hemx", + "hemx-axum", + "hemx-build", + "hemx-host", + "hemx-test", + "tokio", +] + [[package]] name = "hemx-xtask" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8b49a8c..eb83fb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas"] +members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas", "examples/workout"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index fcae117..1cbf7c5 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ See `docs/versioning.md`. walkthrough in `docs/tutorial-saas.md`; the SQLx persistence recipe in `docs/recipes/sqlx-persistence.md` shows the provider boundary without moving SQL into core. +- `examples/workout`: phone-first local-first product exemplar. Run with + `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`. + It keeps workout truth as commands/events/projections and routes export + through the host capability boundary. req: examples/001 req: local/001 req: host/005 - `examples/kanban`: advanced / north-star milestone boundary sketch. It may expose manual registry or render escape hatches while exploring product limits. - `examples/techdemo`: advanced integration demo with a leaf island and broader diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 5b4af4b..921aac5 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; 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 host export; 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/Cargo.toml b/examples/workout/Cargo.toml new file mode 100644 index 0000000..ab8b712 --- /dev/null +++ b/examples/workout/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "hemx-workout-example" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "hemx-workout-example" +path = "src/main.rs" + +[dependencies] +axum = "0.7" +hemplate = { path = "../../../hemplate/hemplate" } +hemx = { path = "../../hemx" } +hemx-axum = { path = "../../hemx-axum" } +hemx-host = { path = "../../hemx-host" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } + +[dev-dependencies] +hemx-test = { path = "../../hemx-test" } + +[build-dependencies] +hemx-build = { path = "../../hemx-build" } diff --git a/examples/workout/build.rs b/examples/workout/build.rs new file mode 100644 index 0000000..99fa6f3 --- /dev/null +++ b/examples/workout/build.rs @@ -0,0 +1,3 @@ +fn main() { + hemx_build::app().run().unwrap(); +} diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs new file mode 100644 index 0000000..b908017 --- /dev/null +++ b/examples/workout/src/lib.rs @@ -0,0 +1,437 @@ +use hemplate::Hemplate; +use hemx::{Html, IntoEffect}; +use hemx_host::{ + browser_pwa_host_profile, Capability, CapabilityManifest, CapabilityShape, CapabilityUse, + HostCall, HostCallId, HostEvent, SharePayload, +}; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +#[hemx::surface] +pub mod ui {} + +#[derive(Clone, Debug, PartialEq)] +pub struct ExercisePlan { + pub name: &'static str, + pub target_sets: u8, + pub reps: u8, + pub kg: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WorkoutCommand { + CompleteSet, + ChangeWeight { kg: f32 }, + SkipExercise, + RecordNote { text: String }, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum WorkoutEvent { + SetCompleted { + exercise: String, + set: u8, + reps: u8, + kg: f32, + }, + WeightChanged { + exercise: String, + kg: f32, + }, + ExerciseSkipped { + exercise: String, + }, + NoteRecorded { + text: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WorkoutProjection { + pub current_exercise: usize, + pub completed_sets_for_current: u8, + pub next_action: String, + pub exported: bool, +} + +#[derive(Clone, Debug)] +pub struct WorkoutState { + pub plan: Vec, + pub commands: Vec, + pub events: Vec, + pub projection: WorkoutProjection, + pub activity: VecDeque, + pub host_status: String, +} + +#[derive(Clone)] +pub struct AppState { + workout: Arc>, +} + +impl AppState { + pub fn demo() -> Self { + Self { + workout: Arc::new(Mutex::new(WorkoutState::demo())), + } + } + + pub fn with_workout(&self, f: impl FnOnce(&WorkoutState) -> R) -> R { + let workout = self.workout.lock().unwrap(); + f(&workout) + } + + fn update(&self, f: impl FnOnce(&mut WorkoutState) -> R) -> R { + let mut workout = self.workout.lock().unwrap(); + f(&mut workout) + } +} + +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, + }, + ], + commands: Vec::new(), + events: Vec::new(), + projection: WorkoutProjection { + current_exercise: 0, + completed_sets_for_current: 0, + next_action: String::new(), + exported: false, + }, + activity: VecDeque::new(), + host_status: "Export waits for an explicit host result.".into(), + }; + state.refresh_next_action(); + state + } + + fn accept(&mut self, command: WorkoutCommand) -> Option { + // req: local/001 req: local/004 + let event = self.validate(&command)?; + self.commands.push(command); + self.events.push(event.clone()); + self.project(&event); + Some(event) + } + + 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::RecordNote { text } if !text.trim().is_empty() => { + Some(WorkoutEvent::NoteRecorded { + text: text.trim().into(), + }) + } + WorkoutCommand::RecordNote { .. } => None, + } + } + + fn project(&mut self, event: &WorkoutEvent) { + // req: local/001 req: local/004 + match event { + WorkoutEvent::SetCompleted { set, .. } => { + 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; + } + } + } + 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; + self.projection.completed_sets_for_current = 0; + } + WorkoutEvent::NoteRecorded { .. } => {} + } + self.refresh_next_action(); + self.activity.push_front(event.summary()); + while self.activity.len() > 5 { + self.activity.pop_back(); + } + } + + 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()); + } + + pub fn event_log_text(&self) -> String { + if self.events.is_empty() { + return "[]".into(); + } + self.events + .iter() + .enumerate() + .map(|(index, event)| format!("{}: {}", index + 1, event.summary())) + .collect::>() + .join("\n") + } +} + +impl WorkoutEvent { + fn summary(&self) -> String { + match self { + Self::SetCompleted { + exercise, + set, + reps, + kg, + } => { + format!("completed {exercise} set {set}: {reps} reps @ {kg} kg") + } + Self::WeightChanged { exercise, kg } => format!("changed {exercise} to {kg} kg"), + Self::ExerciseSkipped { exercise } => format!("skipped {exercise}"), + Self::NoteRecorded { text } => format!("note: {text}"), + } + } +} + +#[derive(Hemplate)] +pub struct Workout { + pub next_action: String, + pub status: String, + pub event_log: String, + pub host_status: String, +} + +pub fn view(state: &WorkoutState) -> Workout { + 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() + ), + event_log: state.event_log_text(), + host_status: state.host_status.clone(), + } +} + +pub fn render(state: &WorkoutState) -> Html { + ui::render(&view(state)) +} + +fn effects(state: &WorkoutState, status: impl Into) -> impl IntoEffect { + // req: local/001 req: local/004 + ( + ui::workout::next_action.text(&state.projection.next_action), + ui::workout::status.text(status.into()), + ui::workout::event_log.text(state.event_log_text()), + ui::workout::host_status.text(&state.host_status), + ) +} + +pub fn complete_set(app: &AppState) -> impl IntoEffect { + app.update(|state| { + let event = state.accept(WorkoutCommand::CompleteSet); + effects( + state, + event.map_or_else(|| "No set available".into(), |event| event.summary()), + ) + }) +} + +pub fn change_weight(app: &AppState, kg: f32) -> impl IntoEffect { + app.update(|state| { + let event = state.accept(WorkoutCommand::ChangeWeight { kg }); + effects( + state, + event.map_or_else(|| "No exercise available".into(), |event| event.summary()), + ) + }) +} + +pub fn skip_exercise(app: &AppState) -> impl IntoEffect { + app.update(|state| { + let event = state.accept(WorkoutCommand::SkipExercise); + effects( + state, + event.map_or_else(|| "No exercise available".into(), |event| event.summary()), + ) + }) +} + +pub fn record_note(app: &AppState, text: impl Into) -> impl IntoEffect { + app.update(|state| { + let event = state.accept(WorkoutCommand::RecordNote { text: text.into() }); + effects( + state, + event.map_or_else(|| "Ignored empty note".into(), |event| event.summary()), + ) + }) +} + +pub fn export_log(app: &AppState) -> impl IntoEffect { + app.update(|state| { + // req: host/001 req: host/004 req: local/003 + let manifest = CapabilityManifest::new([CapabilityUse::new( + Capability::Share, + CapabilityShape::Request, + )]); + let call = HostCall::Share { + id: HostCallId::new("workout-export"), + payload: SharePayload::text(state.event_log_text()), + }; + 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}"), + }; + effects(state, "Export request checked against host manifest") + }) +} + +pub fn record_share_result(app: &AppState) -> impl IntoEffect { + app.update(|state| { + // req: host/002 req: host/005 req: local/003 + apply_host_event( + state, + HostEvent::ShareCompleted { + id: HostCallId::new("workout-export"), + completed: true, + }, + ); + effects(state, "Host result accepted by app code") + }) +} + +fn apply_host_event(state: &mut WorkoutState, event: HostEvent) { + match event { + HostEvent::ShareCompleted { + completed: true, .. + } => { + state.projection.exported = true; + state.host_status = + "Host share completed; event log remains replayable local truth.".into(); + } + HostEvent::ShareCompleted { + completed: false, .. + } => { + state.host_status = "Host share cancelled; local event log unchanged.".into(); + } + _ => { + state.host_status = "Host event ignored by app policy.".into(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hemx::advanced::{Effect, Payload}; + use hemx_test::run; + + fn contains_payload_text(effects: &hemx_test::EffectInspector, needle: &str) -> bool { + effects.batch().ops.iter().any(|op| { + matches!(op, Effect::Put { payload: Payload::Text(text), .. } if text.contains(needle)) + }) + } + + #[test] + fn local_command_projects_before_ui_effects() { + // req: local/001 req: local/004 + let app = AppState::demo(); + let effects = run(|()| complete_set(&app), ()); + + assert_eq!( + app.with_workout(|state| ( + state.commands.len(), + state.events.len(), + state.projection.completed_sets_for_current + )), + (1, 1, 1) + ); + assert!(contains_payload_text( + &effects, + "completed Goblet squat set 1" + )); + assert!(contains_payload_text( + &effects, + "Next: Goblet squat set 2/3" + )); + assert_eq!( + app.with_workout(|state| (state.commands.len(), state.events.len())), + (1, 1) + ); + } + + #[test] + fn host_export_result_returns_through_app_code() { + // req: host/001 req: host/002 req: host/005 req: local/003 + let app = AppState::demo(); + run(|()| complete_set(&app), ()); + + let export = run(|()| export_log(&app), ()); + assert!(contains_payload_text( + &export, + "Export request checked against host manifest" + )); + assert!(contains_payload_text(&export, "waiting for host result")); + + let shared = run(|()| record_share_result(&app), ()); + assert!(app.with_workout(|state| state.projection.exported)); + assert!(contains_payload_text( + &shared, + "Host result accepted by app code" + )); + assert!(contains_payload_text( + &shared, + "event log remains replayable local truth" + )); + } + + #[test] + fn rendered_page_has_phone_first_controls_without_user_js() { + // req: examples/001 req: examples/005 + let html = render(&WorkoutState::demo()).to_string(); + assert!(html.contains("Now-first Workout Copilot")); + assert!(html.contains("Complete set")); + assert!(html.contains("Export via host share")); + assert!(!html.contains(" Router { + Router::new() + .route("/", get(page).post(interact)) + .route(runtime_js_path(), get(runtime)) + .with_state(state) +} + +async fn page(State(state): State) -> impl IntoResponse { + axum::response::Html(state.with_workout(|workout| workout_app::render(workout).into_string())) +} + +async fn interact( + State(state): State, + request: InteractionRequest, +) -> Result { + request.dispatch_async(registry(state)).await +} + +async fn runtime() -> impl IntoResponse { + runtime_js() +} + +fn registry(state: AppState) -> HandlerRegistry { + interactions(hemx_workout_example::ui::BUILD_FINGERPRINT) + .on(workout::complete_set, { + let state = state.clone(); + move |_| workout_app::complete_set(&state) + }) + .on(workout::change_weight, { + let state = state.clone(); + move |form| workout_app::change_weight(&state, form.parse::("kg").unwrap_or(0.0)) + }) + .on(workout::skip_exercise, { + let state = state.clone(); + move |_| workout_app::skip_exercise(&state) + }) + .on(workout::record_note, { + let state = state.clone(); + move |form| { + workout_app::record_note(&state, form.value("text").unwrap_or("").to_owned()) + } + }) + .on(workout::export_log, { + let state = state.clone(); + move |_| workout_app::export_log(&state) + }) + .on(workout::record_share_result, move |_| { + workout_app::record_share_result(&state) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn routes_can_be_built_for_run_and_deploy_smoke() { + let _app = app(AppState::demo()); + } +} diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml new file mode 100644 index 0000000..1512a35 --- /dev/null +++ b/examples/workout/templates/workout.heml @@ -0,0 +1,32 @@ +
+
+

Now-first Workout Copilot

+

{+ self.next_action +}

+

{+ self.status +}

+
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+

Private event log

+
{+ self.event_log +}
+
+ +
+

Export host boundary

+

{+ self.host_status +}

+ + +
+