Added the functionality to insert a workout

This commit is contained in:
2025-07-15 13:53:05 +02:00
parent 9a8cd2ed86
commit d1029485d4
23 changed files with 847 additions and 136 deletions

319
src/pages/workouts/new.rs Normal file
View File

@@ -0,0 +1,319 @@
use crate::components::multiselect;
use crate::icons;
use crate::models::exercises::ExerciseVariant;
use crate::models::workouts::WorkoutExercise;
use crate::util::string_into_vec;
use crate::{AppError, layouts, util::AppState};
use axum::response::IntoResponse;
use axum::routing::{delete_service, post};
use axum::{Router, extract::State, http::StatusCode, routing::get};
use axum_extra::extract::Form;
use itertools::Itertools;
use maud::{Markup, html};
use serde::Deserialize;
use uuid::Uuid;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(page).post(submit))
.route("/exercise_list", post(exercise_list))
}
async fn page(State(state): State<AppState>) -> Result<Markup, AppError> {
let muscles = sqlx::query_as!(crate::models::Muscle, "SELECT * FROM muscles")
.fetch_all(&state.pool)
.await?;
let categories = sqlx::query_as!(crate::models::Category, "SELECT * FROM categories")
.fetch_all(&state.pool)
.await?;
let content = html! {
h1 class="mb-5" { "New Workout" }
div x-data="{ exercises: [] }" {
form hx-post="/workouts/new" class="space-y-1" {
fieldset class="fieldset" {
legend class="fieldset-legend" { "Name" }
input required="true" name="name" class="input" {}
}
fieldset class="fieldset" {
legend class="fieldset-legend" { "Description" }
textarea required="true" name="description" class="textarea" {}
}
h2 class="mb-3" { "Exercises" }
ul class="list space-y-3" {
template x-for="(value, index) in exercises" {
li class="bg-base-200 p-3 rounded" {
div class="mb-3" {
div class="flex" {
h3 x-text="value.name" class="" {}
button type="button" class="btn btn-error btn-sm ml-auto" x-on:click="exercises.splice(index, 1)" { "Delete" }
}
div x-text="value.description" class="text-sm opacity-50" {}
}
div class="mb-3" {
select x-model="value.type" class="select select-sm" {
option disabled selected value="none" { "Type" }
option value="time" { "Time" }
option value="number" { "Sets/ Reps" }
option value="failure" { "Failure" }
}
}
div class="" {
input type="hidden" name="exercises" x-bind:value="JSON.stringify(value)" {}
template x-if="value.type === 'time'" {
div {
fieldset class="fieldset" {
legend class="fieldset-legend" { "Time in seconds" }
input "x-model.number"="value.time" type="number" class="input input-sm" required min="1" title="Must be at least 1 second" {}
}
}
}
template x-if="value.type === 'number'" {
div {
fieldset class="fieldset" {
legend class="fieldset-legend" { "Number of sets" }
input "x-model.number"="value.sets" type="number" class="input input-sm" required min="1" title="Must be at least 1" {}
}
fieldset class="fieldset" {
legend class="fieldset-legend" { "Number of reps per set" }
input "x-model.number"="value.reps" type="number" class="input input-sm" required min="1" title="Must be at least 1" {}
}
}
}
}
}
}
}
button type="button" onclick="exercise_modal.showModal()" class="btn" { "Add exercise +" }
input type="submit" class="btn" value="save" { }
}
dialog id="exercise_modal" class="modal modal-bottom sm:modal-middle" {
div class="modal-box" {
h3 class="mb-3" { "Search Exercises"}
label class="input mb-2 w-full" {
div class="h-[1.2em] opacity-50" {
(icons::search())
}
input type="search" name="search" hx-post="/workouts/new/exercise_list" hx-trigger="input changed delay:500ms, keyup[key=='enter'], load" hx-target="#exercise_list" class="grow" placeholder="Search" {}
}
div class="flex flex-wrap gap-2 mb-5" {
select class="select select-sm w-32" {
option selected { "All Muscles" }
@for muscle in muscles {
option value=(muscle.muscle_id) { (muscle.name) }
}
}
select class="select select-sm w-36" {
option selected { "All Categories" }
@for category in categories {
option value=(category.category_id) { (category.name) }
}
}
}
ul id="exercise_list" {}
div class="modal-action" {
form method="dialog" novalidate {
button class="btn" { "Close" }
}
}
}
}
}
};
Ok(layouts::desktop(content, "New Exercise"))
}
#[derive(Deserialize, Debug)]
struct FormData {
name: String,
description: String,
exercises: Vec<String>,
}
#[derive(Deserialize, Debug)]
#[serde(tag = "type")]
enum ExerciseType {
#[serde(rename = "time")]
ExerciseTime(ExerciseTime),
#[serde(rename = "number")]
ExerciseNumber(ExerciseNumber),
#[serde(rename = "failure")]
ExerciseFailure(ExerciseFailure),
}
#[derive(Deserialize, Debug)]
struct ExerciseTime {
id: Uuid,
time: i32,
}
#[derive(Deserialize, Debug)]
struct ExerciseNumber {
id: Uuid,
sets: i32,
reps: i32,
}
#[derive(Deserialize, Debug)]
struct ExerciseFailure {
id: Uuid,
}
fn from_exercise_type_into_workout_exercise(
exercise_type: ExerciseType,
workout_id: Uuid,
position: i32,
) -> WorkoutExercise {
match exercise_type {
ExerciseType::ExerciseTime(time) => WorkoutExercise {
workout_id,
exercise_id: time.id,
exercise_type: ExerciseVariant::Time,
position,
sets: None,
reps: None,
time: Some(time.time),
},
ExerciseType::ExerciseNumber(number) => WorkoutExercise {
workout_id,
exercise_id: number.id,
exercise_type: ExerciseVariant::Number,
position,
sets: Some(number.sets),
reps: Some(number.reps),
time: None,
},
ExerciseType::ExerciseFailure(failure) => WorkoutExercise {
workout_id,
exercise_id: failure.id,
exercise_type: ExerciseVariant::Failure,
position,
sets: None,
reps: None,
time: None,
},
}
}
async fn submit(
State(state): State<AppState>,
Form(form): Form<FormData>,
) -> Result<impl IntoResponse, AppError> {
let workout_id = Uuid::new_v4();
let mut exercises: Vec<ExerciseType> = vec![];
// Convert exercises string into releveant objects
for exercise_str in form.exercises {
tracing::info!("exercise string: {:?}", exercise_str);
match serde_json::from_str::<ExerciseType>(&exercise_str) {
Ok(ex) => exercises.push(ex),
Err(err) => tracing::error!("Failed to parse exercise: {:?}", err),
}
}
tracing::info!("{:?}", exercises);
let (workout_ids, exercise_ids, exercise_types, positions, sets, reps, times): (
Vec<_>,
Vec<_>,
Vec<_>,
Vec<_>,
Vec<_>,
Vec<_>,
Vec<_>,
) = exercises
.into_iter()
.enumerate()
.map(|(index, e)| {
let we = from_exercise_type_into_workout_exercise(e, workout_id, index as i32);
(
we.workout_id,
we.exercise_id,
we.exercise_type,
we.position,
we.sets,
we.reps,
we.time,
)
})
.multiunzip();
let mut transaction = state.pool.begin().await?;
sqlx::query!(
"INSERT INTO workouts (workout_id, name, description) VALUES ($1, $2, $3)",
&workout_id,
&form.name,
&form.description,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
INSERT INTO workout_exercises (workout_id, exercise_id, exercise_type, position, sets, reps, time)
SELECT * FROM UNNEST($1::uuid[], $2::uuid[], $3::exercise_variant[], $4::int[], $5::int[], $6::int[], $7::int[])
",
&workout_ids,
&exercise_ids,
&exercise_types as &[ExerciseVariant],
&positions,
&sets as &[Option<i32>],
&reps as &[Option<i32>],
&times as &[Option<i32>],
).execute(&mut *transaction).await?;
transaction.commit().await?;
Ok(html! {
p {
"New exercise has been created!"
}
})
}
#[derive(Debug, Deserialize)]
pub struct SearchData {
search: String,
}
async fn exercise_list(
State(state): State<AppState>,
Form(form): Form<SearchData>,
) -> Result<Markup, AppError> {
let pattern = format!("%{}%", form.search);
let exercises = sqlx::query_as!(
crate::models::Exercise,
"SELECT exercise_id, name, description FROM exercises WHERE name ILIKE $1",
&pattern
)
.fetch_all(&state.pool)
.await?;
Ok(html! {
ul class="list" id="exercise_list" {
@for exercise in exercises {
li x-on:click={"exercises.push({ id: '"(exercise.exercise_id)"', name: '"(exercise.name)"', description: '"(exercise.description)"', type: 'none' }); exercise_modal.close()"} class="list-row py-2 hover:cursor-pointer" {
div {
div class="font-bold" { (exercise.name) }
div { (exercise.description) }
}
}
}
}
})
}