refactor(examples): shrink workout route wiring

Move workout interaction wiring behind an app-owned registry function and use typed form adapters so the runnable binary no longer carries repetitive per-handler closure boilerplate.

req: codegen/002

req: examples/001
This commit is contained in:
slhx agent
2026-06-11 21:44:11 +02:00
parent 5e4f02f419
commit d991addbb5
2 changed files with 60 additions and 67 deletions
+58 -18
View File
@@ -1,5 +1,6 @@
use hemplate::Hemplate; use hemplate::Hemplate;
use hemx::{Html, IntoEffect}; use hemx::{Html, IntoEffect};
use hemx_axum::{interactions, Form, HandlerRegistry};
use hemx_host::{ use hemx_host::{
browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest, browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest,
CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent,
@@ -350,6 +351,37 @@ pub fn render(state: &WorkoutState) -> Html {
ui::render(&view(state)) ui::render(&view(state))
} }
#[derive(Clone, Debug)]
#[hemx::form("change_weight")]
pub struct ChangeWeightInput {
kg: f32,
}
#[derive(Clone, Debug)]
#[hemx::form("record_note")]
pub struct RecordNoteInput {
text: String,
}
pub fn registry(state: AppState) -> HandlerRegistry {
// req: codegen/002 req: examples/001
interactions(ui::BUILD_FINGERPRINT)
.with_state(state)
.on_state(ui::workout::complete_set, complete_set)
.on(ui::workout::change_weight, change_weight_form)
.on_state(ui::workout::skip_exercise, skip_exercise)
.on(ui::workout::record_note, record_note_form)
.on_state(ui::workout::replay_export, replay_export)
.on_state(ui::workout::export_log, export_log)
.on_state(ui::workout::record_share_result, record_share_result)
.on_state(ui::workout::request_native_haptic, request_native_haptic)
.on_state(
ui::workout::record_native_haptic_ack,
record_native_haptic_ack,
)
.into_registry()
}
fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect { fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect {
// req: local/001 req: local/004 // req: local/001 req: local/004
( (
@@ -361,7 +393,7 @@ fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect {
) )
} }
pub fn complete_set(app: &AppState) -> impl IntoEffect { pub fn complete_set(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
let event = state.accept(WorkoutCommand::CompleteSet); let event = state.accept(WorkoutCommand::CompleteSet);
effects( effects(
@@ -371,7 +403,7 @@ pub fn complete_set(app: &AppState) -> impl IntoEffect {
}) })
} }
pub fn change_weight(app: &AppState, kg: f32) -> impl IntoEffect { pub fn change_weight(app: AppState, kg: f32) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
let event = state.accept(WorkoutCommand::ChangeWeight { kg }); let event = state.accept(WorkoutCommand::ChangeWeight { kg });
effects( effects(
@@ -381,7 +413,7 @@ pub fn change_weight(app: &AppState, kg: f32) -> impl IntoEffect {
}) })
} }
pub fn skip_exercise(app: &AppState) -> impl IntoEffect { pub fn skip_exercise(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
let event = state.accept(WorkoutCommand::SkipExercise); let event = state.accept(WorkoutCommand::SkipExercise);
effects( effects(
@@ -391,7 +423,15 @@ pub fn skip_exercise(app: &AppState) -> impl IntoEffect {
}) })
} }
pub fn record_note(app: &AppState, text: impl Into<String>) -> impl IntoEffect { pub fn change_weight_form(app: AppState, Form(input): Form<ChangeWeightInput>) -> impl IntoEffect {
change_weight(app, input.kg)
}
pub fn record_note_form(app: AppState, Form(input): Form<RecordNoteInput>) -> impl IntoEffect {
record_note(app, input.text)
}
pub fn record_note(app: AppState, text: impl Into<String>) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
let event = state.accept(WorkoutCommand::RecordNote { text: text.into() }); let event = state.accept(WorkoutCommand::RecordNote { text: text.into() });
effects( effects(
@@ -401,7 +441,7 @@ pub fn record_note(app: &AppState, text: impl Into<String>) -> impl IntoEffect {
}) })
} }
pub fn replay_export(app: &AppState) -> impl IntoEffect { pub fn replay_export(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
// req: local/001 req: local/003 req: local/004 // req: local/001 req: local/003 req: local/004
let export = state.export_event_log(); let export = state.export_event_log();
@@ -410,7 +450,7 @@ pub fn replay_export(app: &AppState) -> impl IntoEffect {
}) })
} }
pub fn export_log(app: &AppState) -> impl IntoEffect { pub fn export_log(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
// req: host/001 req: host/004 req: local/003 // req: host/001 req: host/004 req: local/003
let manifest = CapabilityManifest::new([CapabilityUse::new( let manifest = CapabilityManifest::new([CapabilityUse::new(
@@ -429,7 +469,7 @@ pub fn export_log(app: &AppState) -> impl IntoEffect {
}) })
} }
pub fn record_share_result(app: &AppState) -> impl IntoEffect { pub fn record_share_result(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
// req: host/002 req: host/005 req: local/003 // req: host/002 req: host/005 req: local/003
apply_host_event( apply_host_event(
@@ -443,7 +483,7 @@ pub fn record_share_result(app: &AppState) -> impl IntoEffect {
}) })
} }
pub fn request_native_haptic(app: &AppState) -> impl IntoEffect { pub fn request_native_haptic(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
// req: host/001 req: host/004 // req: host/001 req: host/004
let manifest = CapabilityManifest::new([CapabilityUse::new( let manifest = CapabilityManifest::new([CapabilityUse::new(
@@ -467,7 +507,7 @@ pub fn request_native_haptic(app: &AppState) -> impl IntoEffect {
}) })
} }
pub fn record_native_haptic_ack(app: &AppState) -> impl IntoEffect { pub fn record_native_haptic_ack(app: AppState) -> impl IntoEffect {
app.update(|state| { app.update(|state| {
// req: host/002 req: host/005 // req: host/002 req: host/005
apply_host_event( apply_host_event(
@@ -520,7 +560,7 @@ mod tests {
fn local_command_projects_before_ui_effects() { fn local_command_projects_before_ui_effects() {
// req: local/001 req: local/004 // req: local/001 req: local/004
let app = AppState::demo(); let app = AppState::demo();
let effects = run(|()| complete_set(&app), ()); let effects = run(|()| complete_set(app.clone()), ());
assert_eq!( assert_eq!(
app.with_workout(|state| ( app.with_workout(|state| (
@@ -548,13 +588,13 @@ mod tests {
fn export_payload_replays_into_projection_before_ui_effects() { fn export_payload_replays_into_projection_before_ui_effects() {
// req: local/001 req: local/003 req: local/004 // req: local/001 req: local/003 req: local/004
let app = AppState::demo(); let app = AppState::demo();
run(|()| complete_set(&app), ()); run(|()| complete_set(app.clone()), ());
run(|()| change_weight(&app, 28.0), ()); run(|()| change_weight(app.clone(), 28.0), ());
let export = app.with_workout(WorkoutState::export_event_log); let export = app.with_workout(WorkoutState::export_event_log);
assert!(export.contains("set_completed\tGoblet squat\t1\t8\t24")); assert!(export.contains("set_completed\tGoblet squat\t1\t8\t24"));
assert!(export.contains("weight_changed\tGoblet squat\t28")); assert!(export.contains("weight_changed\tGoblet squat\t28"));
let replay = run(|()| replay_export(&app), ()); let replay = run(|()| replay_export(app.clone()), ());
assert!(contains_payload_text( assert!(contains_payload_text(
&replay, &replay,
"Replayed 2 exported workout events" "Replayed 2 exported workout events"
@@ -575,16 +615,16 @@ mod tests {
fn host_export_result_returns_through_app_code() { fn host_export_result_returns_through_app_code() {
// req: host/001 req: host/002 req: host/005 req: local/003 // req: host/001 req: host/002 req: host/005 req: local/003
let app = AppState::demo(); let app = AppState::demo();
run(|()| complete_set(&app), ()); run(|()| complete_set(app.clone()), ());
let export = run(|()| export_log(&app), ()); let export = run(|()| export_log(app.clone()), ());
assert!(contains_payload_text( assert!(contains_payload_text(
&export, &export,
"Export request checked against host manifest" "Export request checked against host manifest"
)); ));
assert!(contains_payload_text(&export, "waiting for host result")); assert!(contains_payload_text(&export, "waiting for host result"));
let shared = run(|()| record_share_result(&app), ()); let shared = run(|()| record_share_result(app.clone()), ());
assert!(app.with_workout(|state| state.projection.exported)); assert!(app.with_workout(|state| state.projection.exported));
assert!(contains_payload_text( assert!(contains_payload_text(
&shared, &shared,
@@ -601,7 +641,7 @@ mod tests {
// req: host/001 req: host/002 req: host/005 // req: host/001 req: host/002 req: host/005
let app = AppState::demo(); let app = AppState::demo();
let request = run(|()| request_native_haptic(&app), ()); let request = run(|()| request_native_haptic(app.clone()), ());
assert!(contains_payload_text( assert!(contains_payload_text(
&request, &request,
"Native-shell host call checked against manifest" "Native-shell host call checked against manifest"
@@ -611,7 +651,7 @@ mod tests {
"waiting for host acknowledgment" "waiting for host acknowledgment"
)); ));
let ack = run(|()| record_native_haptic_ack(&app), ()); let ack = run(|()| record_native_haptic_ack(app.clone()), ());
assert!(contains_payload_text( assert!(contains_payload_text(
&ack, &ack,
"Native-shell host result accepted by app code" "Native-shell host result accepted by app code"
+2 -49
View File
@@ -2,8 +2,7 @@ use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::Router;
use hemx_axum::{interactions, runtime_js, runtime_js_path, EffectResponse, InteractionRequest}; use hemx_axum::{runtime_js, runtime_js_path, EffectResponse, InteractionRequest};
use hemx_workout_example::ui::workout;
use hemx_workout_example::{self as workout_app, AppState}; use hemx_workout_example::{self as workout_app, AppState};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::str::FromStr; use std::str::FromStr;
@@ -39,53 +38,7 @@ async fn interact(
State(state): State<AppState>, State(state): State<AppState>,
request: InteractionRequest, request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> { ) -> Result<EffectResponse, impl IntoResponse> {
request request.dispatch_async(workout_app::registry(state)).await
.dispatch_async(
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::replay_export, {
let state = state.clone();
move |_| workout_app::replay_export(&state)
})
.on(workout::export_log, {
let state = state.clone();
move |_| workout_app::export_log(&state)
})
.on(workout::record_share_result, {
let state = state.clone();
move |_| workout_app::record_share_result(&state)
})
.on(workout::request_native_haptic, {
let state = state.clone();
move |_| workout_app::request_native_haptic(&state)
})
.on(workout::record_native_haptic_ack, move |_| {
workout_app::record_native_haptic_ack(&state)
}),
)
.await
} }
async fn runtime() -> impl IntoResponse { async fn runtime() -> impl IntoResponse {