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
This commit is contained in:
slhx agent
2026-06-11 20:38:06 +02:00
parent 40b9bcb5fd
commit 19bf47ec72
9 changed files with 604 additions and 2 deletions
+26
View File
@@ -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" }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
hemx_build::app().run().unwrap();
}
+437
View File
@@ -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<ExercisePlan>,
pub commands: Vec<WorkoutCommand>,
pub events: Vec<WorkoutEvent>,
pub projection: WorkoutProjection,
pub activity: VecDeque<String>,
pub host_status: String,
}
#[derive(Clone)]
pub struct AppState {
workout: Arc<Mutex<WorkoutState>>,
}
impl AppState {
pub fn demo() -> Self {
Self {
workout: Arc::new(Mutex::new(WorkoutState::demo())),
}
}
pub fn with_workout<R>(&self, f: impl FnOnce(&WorkoutState) -> R) -> R {
let workout = self.workout.lock().unwrap();
f(&workout)
}
fn update<R>(&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<WorkoutEvent> {
// 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<WorkoutEvent> {
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::<Vec<_>>()
.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<String>) -> 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<String>) -> 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("<script"));
assert!(!html.contains("querySelector"));
}
}
+86
View File
@@ -0,0 +1,86 @@
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use hemx_axum::{
interactions, runtime_js, runtime_js_path, EffectResponse, HandlerRegistry, InteractionRequest,
};
use hemx_workout_example::ui::workout;
use hemx_workout_example::{self as workout_app, AppState};
use std::net::SocketAddr;
use std::str::FromStr;
#[tokio::main]
async fn main() {
let app = app(AppState::demo());
let addr = std::env::var("HEMX_WORKOUT_ADDR")
.ok()
.and_then(|value| SocketAddr::from_str(&value).ok())
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 3028)));
let listener = tokio::net::TcpListener::bind(addr).await.expect(
"bind workout example address; set HEMX_WORKOUT_ADDR=127.0.0.1:3030 if the default is busy",
);
eprintln!("hemx workout example: http://{addr}");
axum::serve(listener, app).await.unwrap();
}
fn app(state: AppState) -> Router {
Router::new()
.route("/", get(page).post(interact))
.route(runtime_js_path(), get(runtime))
.with_state(state)
}
async fn page(State(state): State<AppState>) -> impl IntoResponse {
axum::response::Html(state.with_workout(|workout| workout_app::render(workout).into_string()))
}
async fn interact(
State(state): State<AppState>,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
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::<f32>("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());
}
}
+32
View File
@@ -0,0 +1,32 @@
<main data-hemx-root="workout" class="workout-app">
<section class="hero">
<p class="eyebrow">Now-first Workout Copilot</p>
<h1 data-hemx-slot="next_action">{+ self.next_action +}</h1>
<p data-hemx-slot="status">{+ self.status +}</p>
</section>
<section class="actions" aria-label="Current workout action">
<button type="button" data-hemx-handle="complete_set">Complete set</button>
<form data-hemx-handle="change_weight" method="post">
<label>Weight <input name="kg" value="52.5" inputmode="decimal"></label>
<button type="submit">Update weight</button>
</form>
<button type="button" data-hemx-handle="skip_exercise">Skip exercise</button>
<form data-hemx-handle="record_note" method="post">
<label>Voice note <input name="text" value="left shoulder felt tight"></label>
<button type="submit">Record dictated note</button>
</form>
</section>
<section class="local-truth">
<h2>Private event log</h2>
<pre data-hemx-slot="event_log">{+ self.event_log +}</pre>
</section>
<section class="host">
<h2>Export host boundary</h2>
<p data-hemx-slot="host_status">{+ self.host_status +}</p>
<button type="button" data-hemx-handle="export_log">Export via host share</button>
<button type="button" data-hemx-handle="record_share_result">Record share completed</button>
</section>
</main>