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 +}

+ + +
+