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:
@@ -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" }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
hemx_build::app().run().unwrap();
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user