diff --git a/src/application/routes/brews.rs b/src/application/routes/brews.rs index cc20149..66f55df 100644 --- a/src/application/routes/brews.rs +++ b/src/application/routes/brews.rs @@ -2,7 +2,7 @@ use axum::Json; use axum::extract::{Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use super::macros::{define_delete_handler, define_enriched_get_handler}; use crate::application::auth::AuthenticatedUser; @@ -117,6 +117,22 @@ pub(crate) async fn brews_page( .map(GearOptionView::from) .collect(); + // Load filter papers for dropdown (optional gear) + let filter_paper_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc); + let filter_papers = state + .gear_repo + .list( + GearFilter::for_category(GearCategory::FilterPaper), + &filter_paper_request, + ) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + let filter_paper_options: Vec = filter_papers + .items + .into_iter() + .map(GearOptionView::from) + .collect(); + let BrewPageData { brews, navigator } = load_brew_page(&state, request) .await .map_err(map_app_error)?; @@ -130,12 +146,34 @@ pub(crate) async fn brews_page( bag_options, grinder_options, brewer_options, + filter_paper_options, navigator, }; render_html(template).map(IntoResponse::into_response) } +/// Deserializes an optional `GearId`, treating empty strings (from HTML forms) as None. +fn deserialize_optional_gear_id<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value: Option = Option::deserialize(deserializer)?; + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) if s.is_empty() => Ok(None), + Some(serde_json::Value::Number(n)) => n + .as_i64() + .map(|id| Some(GearId::new(id))) + .ok_or_else(|| serde::de::Error::custom("invalid gear id")), + Some(serde_json::Value::String(s)) => s + .parse::() + .map(|id| Some(GearId::new(id))) + .map_err(serde::de::Error::custom), + Some(_) => Err(serde::de::Error::custom("invalid gear id")), + } +} + #[derive(Debug, Deserialize)] pub(crate) struct NewBrewSubmission { bag_id: BagId, @@ -143,6 +181,8 @@ pub(crate) struct NewBrewSubmission { grinder_id: GearId, grind_setting: f64, brewer_id: GearId, + #[serde(default, deserialize_with = "deserialize_optional_gear_id")] + filter_paper_id: Option, water_volume: i32, water_temp: f64, } @@ -170,6 +210,7 @@ impl NewBrewSubmission { grinder_id: self.grinder_id, grind_setting: self.grind_setting, brewer_id: self.brewer_id, + filter_paper_id: self.filter_paper_id, water_volume: self.water_volume, water_temp: self.water_temp, }) @@ -248,44 +289,54 @@ fn brew_timeline_event(enriched: &BrewWithDetails) -> NewTimelineEvent { "N/A".to_string() }; + let mut details = vec![ + TimelineEventDetail { + label: "Roaster".to_string(), + value: enriched.roaster_name.clone(), + }, + TimelineEventDetail { + label: "Coffee".to_string(), + value: format!("{:.1}g", enriched.brew.coffee_weight), + }, + TimelineEventDetail { + label: "Water".to_string(), + value: format!( + "{}ml @ {:.1}\u{00B0}C", + enriched.brew.water_volume, enriched.brew.water_temp + ), + }, + TimelineEventDetail { + label: "Grinder".to_string(), + value: format!( + "{} @ {:.1}", + enriched.grinder_name, enriched.brew.grind_setting + ), + }, + TimelineEventDetail { + label: "Brewer".to_string(), + value: enriched.brewer_name.clone(), + }, + ]; + + if let Some(ref fp_name) = enriched.filter_paper_name { + details.push(TimelineEventDetail { + label: "Filter".to_string(), + value: fp_name.clone(), + }); + } + + details.push(TimelineEventDetail { + label: "Ratio".to_string(), + value: ratio, + }); + NewTimelineEvent { entity_type: "brew".to_string(), entity_id: enriched.brew.id.into_inner(), action: "brewed".to_string(), occurred_at: chrono::Utc::now(), title: enriched.roast_name.clone(), - details: vec![ - TimelineEventDetail { - label: "Roaster".to_string(), - value: enriched.roaster_name.clone(), - }, - TimelineEventDetail { - label: "Coffee".to_string(), - value: format!("{:.1}g", enriched.brew.coffee_weight), - }, - TimelineEventDetail { - label: "Water".to_string(), - value: format!( - "{}ml @ {:.1}\u{00B0}C", - enriched.brew.water_volume, enriched.brew.water_temp - ), - }, - TimelineEventDetail { - label: "Grinder".to_string(), - value: format!( - "{} @ {:.1}", - enriched.grinder_name, enriched.brew.grind_setting - ), - }, - TimelineEventDetail { - label: "Brewer".to_string(), - value: enriched.brewer_name.clone(), - }, - TimelineEventDetail { - label: "Ratio".to_string(), - value: ratio, - }, - ], + details, tasting_notes: vec![], slug: Some(enriched.roast_slug.clone()), roaster_slug: Some(enriched.roaster_slug.clone()), @@ -293,6 +344,10 @@ fn brew_timeline_event(enriched: &BrewWithDetails) -> NewTimelineEvent { bag_id: enriched.brew.bag_id.into_inner(), grinder_id: enriched.brew.grinder_id.into_inner(), brewer_id: enriched.brew.brewer_id.into_inner(), + filter_paper_id: enriched + .brew + .filter_paper_id + .map(crate::domain::ids::GearId::into_inner), coffee_weight: enriched.brew.coffee_weight, grind_setting: enriched.brew.grind_setting, water_volume: enriched.brew.water_volume, diff --git a/src/presentation/cli/brews.rs b/src/presentation/cli/brews.rs index 0feb214..85ff48d 100644 --- a/src/presentation/cli/brews.rs +++ b/src/presentation/cli/brews.rs @@ -49,6 +49,10 @@ pub struct AddBrewCommand { #[arg(long)] pub brewer_id: i64, + /// ID of the filter paper to use (optional) + #[arg(long)] + pub filter_paper_id: Option, + /// Volume of water in ml #[arg(long, default_value = "250")] pub water_volume: i32, @@ -67,6 +71,7 @@ pub async fn add_brew(client: &BrewlogClient, command: AddBrewCommand) -> Result GearId::new(command.grinder_id), command.grind_setting, GearId::new(command.brewer_id), + command.filter_paper_id.map(GearId::new), command.water_volume, command.water_temp, ) diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index dd0ed84..db79ab1 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -135,6 +135,7 @@ pub struct BrewsTemplate { pub bag_options: Vec, pub grinder_options: Vec, pub brewer_options: Vec, + pub filter_paper_options: Vec, pub navigator: ListNavigator, } diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs index e42f78c..1ce1830 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -421,6 +421,7 @@ pub struct TimelineBrewDataView { pub bag_id: i64, pub grinder_id: i64, pub brewer_id: i64, + pub filter_paper_id: Option, pub coffee_weight: f64, pub grind_setting: f64, pub water_volume: i32, @@ -522,6 +523,7 @@ impl TimelineEventView { bag_id: bd.bag_id, grinder_id: bd.grinder_id, brewer_id: bd.brewer_id, + filter_paper_id: bd.filter_paper_id, coffee_weight: bd.coffee_weight, grind_setting: bd.grind_setting, water_volume: bd.water_volume, @@ -621,6 +623,8 @@ pub struct BrewView { pub grind_setting: String, pub brewer_id: i64, pub brewer_name: String, + pub filter_paper_id: Option, + pub filter_paper_name: Option, pub water_volume: String, pub water_temp: String, pub ratio: String, @@ -656,6 +660,11 @@ impl BrewView { grind_setting: format!("{:.1}", brew.brew.grind_setting), brewer_id: brew.brew.brewer_id.into_inner(), brewer_name: brew.brewer_name, + filter_paper_id: brew + .brew + .filter_paper_id + .map(crate::domain::ids::GearId::into_inner), + filter_paper_name: brew.filter_paper_name, water_volume: format!("{}ml", brew.brew.water_volume), water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp), ratio, diff --git a/templates/brews.html b/templates/brews.html index 42ca45f..1c1e552 100644 --- a/templates/brews.html +++ b/templates/brews.html @@ -87,6 +87,17 @@ + + +
Coffee (g) * diff --git a/templates/gear.html b/templates/gear.html index b0c6c86..66bf96a 100644 --- a/templates/gear.html +++ b/templates/gear.html @@ -47,6 +47,7 @@ +