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
+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());
}
}