feat(brews): add filter paper to routes, views, CLI, and templates
- Add filter paper dropdown to brew form and gear category form - Add custom deserializer for optional GearId from HTML forms - Load filter papers in brews_page handler - Show filter paper as subtext under brewer in brew list - Add filter paper to timeline event details and brew-again forms - Add --filter-paper-id flag to CLI brew add command
This commit is contained in:
parent
d636f6c246
commit
8b77eb90eb
8 changed files with 127 additions and 34 deletions
|
|
@ -2,7 +2,7 @@ use axum::Json;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Redirect, Response};
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Deserializer};
|
||||||
|
|
||||||
use super::macros::{define_delete_handler, define_enriched_get_handler};
|
use super::macros::{define_delete_handler, define_enriched_get_handler};
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
|
|
@ -117,6 +117,22 @@ pub(crate) async fn brews_page(
|
||||||
.map(GearOptionView::from)
|
.map(GearOptionView::from)
|
||||||
.collect();
|
.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<GearOptionView> = filter_papers
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(GearOptionView::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
let BrewPageData { brews, navigator } = load_brew_page(&state, request)
|
let BrewPageData { brews, navigator } = load_brew_page(&state, request)
|
||||||
.await
|
.await
|
||||||
.map_err(map_app_error)?;
|
.map_err(map_app_error)?;
|
||||||
|
|
@ -130,12 +146,34 @@ pub(crate) async fn brews_page(
|
||||||
bag_options,
|
bag_options,
|
||||||
grinder_options,
|
grinder_options,
|
||||||
brewer_options,
|
brewer_options,
|
||||||
|
filter_paper_options,
|
||||||
navigator,
|
navigator,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
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<Option<GearId>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value: Option<serde_json::Value> = 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::<i64>()
|
||||||
|
.map(|id| Some(GearId::new(id)))
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
Some(_) => Err(serde::de::Error::custom("invalid gear id")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct NewBrewSubmission {
|
pub(crate) struct NewBrewSubmission {
|
||||||
bag_id: BagId,
|
bag_id: BagId,
|
||||||
|
|
@ -143,6 +181,8 @@ pub(crate) struct NewBrewSubmission {
|
||||||
grinder_id: GearId,
|
grinder_id: GearId,
|
||||||
grind_setting: f64,
|
grind_setting: f64,
|
||||||
brewer_id: GearId,
|
brewer_id: GearId,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_gear_id")]
|
||||||
|
filter_paper_id: Option<GearId>,
|
||||||
water_volume: i32,
|
water_volume: i32,
|
||||||
water_temp: f64,
|
water_temp: f64,
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +210,7 @@ impl NewBrewSubmission {
|
||||||
grinder_id: self.grinder_id,
|
grinder_id: self.grinder_id,
|
||||||
grind_setting: self.grind_setting,
|
grind_setting: self.grind_setting,
|
||||||
brewer_id: self.brewer_id,
|
brewer_id: self.brewer_id,
|
||||||
|
filter_paper_id: self.filter_paper_id,
|
||||||
water_volume: self.water_volume,
|
water_volume: self.water_volume,
|
||||||
water_temp: self.water_temp,
|
water_temp: self.water_temp,
|
||||||
})
|
})
|
||||||
|
|
@ -248,44 +289,54 @@ fn brew_timeline_event(enriched: &BrewWithDetails) -> NewTimelineEvent {
|
||||||
"N/A".to_string()
|
"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 {
|
NewTimelineEvent {
|
||||||
entity_type: "brew".to_string(),
|
entity_type: "brew".to_string(),
|
||||||
entity_id: enriched.brew.id.into_inner(),
|
entity_id: enriched.brew.id.into_inner(),
|
||||||
action: "brewed".to_string(),
|
action: "brewed".to_string(),
|
||||||
occurred_at: chrono::Utc::now(),
|
occurred_at: chrono::Utc::now(),
|
||||||
title: enriched.roast_name.clone(),
|
title: enriched.roast_name.clone(),
|
||||||
details: vec![
|
details,
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tasting_notes: vec![],
|
tasting_notes: vec![],
|
||||||
slug: Some(enriched.roast_slug.clone()),
|
slug: Some(enriched.roast_slug.clone()),
|
||||||
roaster_slug: Some(enriched.roaster_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(),
|
bag_id: enriched.brew.bag_id.into_inner(),
|
||||||
grinder_id: enriched.brew.grinder_id.into_inner(),
|
grinder_id: enriched.brew.grinder_id.into_inner(),
|
||||||
brewer_id: enriched.brew.brewer_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,
|
coffee_weight: enriched.brew.coffee_weight,
|
||||||
grind_setting: enriched.brew.grind_setting,
|
grind_setting: enriched.brew.grind_setting,
|
||||||
water_volume: enriched.brew.water_volume,
|
water_volume: enriched.brew.water_volume,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ pub struct AddBrewCommand {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub brewer_id: i64,
|
pub brewer_id: i64,
|
||||||
|
|
||||||
|
/// ID of the filter paper to use (optional)
|
||||||
|
#[arg(long)]
|
||||||
|
pub filter_paper_id: Option<i64>,
|
||||||
|
|
||||||
/// Volume of water in ml
|
/// Volume of water in ml
|
||||||
#[arg(long, default_value = "250")]
|
#[arg(long, default_value = "250")]
|
||||||
pub water_volume: i32,
|
pub water_volume: i32,
|
||||||
|
|
@ -67,6 +71,7 @@ pub async fn add_brew(client: &BrewlogClient, command: AddBrewCommand) -> Result
|
||||||
GearId::new(command.grinder_id),
|
GearId::new(command.grinder_id),
|
||||||
command.grind_setting,
|
command.grind_setting,
|
||||||
GearId::new(command.brewer_id),
|
GearId::new(command.brewer_id),
|
||||||
|
command.filter_paper_id.map(GearId::new),
|
||||||
command.water_volume,
|
command.water_volume,
|
||||||
command.water_temp,
|
command.water_temp,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ pub struct BrewsTemplate {
|
||||||
pub bag_options: Vec<BagOptionView>,
|
pub bag_options: Vec<BagOptionView>,
|
||||||
pub grinder_options: Vec<GearOptionView>,
|
pub grinder_options: Vec<GearOptionView>,
|
||||||
pub brewer_options: Vec<GearOptionView>,
|
pub brewer_options: Vec<GearOptionView>,
|
||||||
|
pub filter_paper_options: Vec<GearOptionView>,
|
||||||
pub navigator: ListNavigator<BrewSortKey>,
|
pub navigator: ListNavigator<BrewSortKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,7 @@ pub struct TimelineBrewDataView {
|
||||||
pub bag_id: i64,
|
pub bag_id: i64,
|
||||||
pub grinder_id: i64,
|
pub grinder_id: i64,
|
||||||
pub brewer_id: i64,
|
pub brewer_id: i64,
|
||||||
|
pub filter_paper_id: Option<i64>,
|
||||||
pub coffee_weight: f64,
|
pub coffee_weight: f64,
|
||||||
pub grind_setting: f64,
|
pub grind_setting: f64,
|
||||||
pub water_volume: i32,
|
pub water_volume: i32,
|
||||||
|
|
@ -522,6 +523,7 @@ impl TimelineEventView {
|
||||||
bag_id: bd.bag_id,
|
bag_id: bd.bag_id,
|
||||||
grinder_id: bd.grinder_id,
|
grinder_id: bd.grinder_id,
|
||||||
brewer_id: bd.brewer_id,
|
brewer_id: bd.brewer_id,
|
||||||
|
filter_paper_id: bd.filter_paper_id,
|
||||||
coffee_weight: bd.coffee_weight,
|
coffee_weight: bd.coffee_weight,
|
||||||
grind_setting: bd.grind_setting,
|
grind_setting: bd.grind_setting,
|
||||||
water_volume: bd.water_volume,
|
water_volume: bd.water_volume,
|
||||||
|
|
@ -621,6 +623,8 @@ pub struct BrewView {
|
||||||
pub grind_setting: String,
|
pub grind_setting: String,
|
||||||
pub brewer_id: i64,
|
pub brewer_id: i64,
|
||||||
pub brewer_name: String,
|
pub brewer_name: String,
|
||||||
|
pub filter_paper_id: Option<i64>,
|
||||||
|
pub filter_paper_name: Option<String>,
|
||||||
pub water_volume: String,
|
pub water_volume: String,
|
||||||
pub water_temp: String,
|
pub water_temp: String,
|
||||||
pub ratio: String,
|
pub ratio: String,
|
||||||
|
|
@ -656,6 +660,11 @@ impl BrewView {
|
||||||
grind_setting: format!("{:.1}", brew.brew.grind_setting),
|
grind_setting: format!("{:.1}", brew.brew.grind_setting),
|
||||||
brewer_id: brew.brew.brewer_id.into_inner(),
|
brewer_id: brew.brew.brewer_id.into_inner(),
|
||||||
brewer_name: brew.brewer_name,
|
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_volume: format!("{}ml", brew.brew.water_volume),
|
||||||
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
|
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
|
||||||
ratio,
|
ratio,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,17 @@
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Filter Paper selector (optional) -->
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Filter Paper</span>
|
||||||
|
<select name="filter_paper_id" class="input-field">
|
||||||
|
<option value="">None</option>
|
||||||
|
{% for fp in filter_paper_options %}
|
||||||
|
<option value="{{ fp.id }}">{{ fp.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<!-- Coffee Weight with +/- buttons -->
|
<!-- Coffee Weight with +/- buttons -->
|
||||||
<div class="flex flex-col gap-1 text-sm">
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Coffee (g) *</span>
|
<span class="text-stone-700">Coffee (g) *</span>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
<option value="">Select a category</option>
|
<option value="">Select a category</option>
|
||||||
<option value="grinder">Grinder</option>
|
<option value="grinder">Grinder</option>
|
||||||
<option value="brewer">Brewer</option>
|
<option value="brewer">Brewer</option>
|
||||||
|
<option value="filter_paper">Filter Paper</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,12 @@
|
||||||
<div class="text-xs text-stone-500 font-mono">{{ brew.ratio }}</div>
|
<div class="text-xs text-stone-500 font-mono">{{ brew.ratio }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">{{ brew.water_temp }}</td>
|
<td class="px-4 py-3 whitespace-nowrap">{{ brew.water_temp }}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap">{{ brew.brewer_name }}</td>
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div>{{ brew.brewer_name }}</div>
|
||||||
|
{% if let Some(fp_name) = brew.filter_paper_name %}
|
||||||
|
<div class="text-xs text-stone-500">{{ fp_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-stone-500">{{ brew.created_at }}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-stone-500">{{ brew.created_at }}</td>
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
|
|
@ -53,6 +58,9 @@
|
||||||
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
||||||
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
|
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
|
||||||
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
||||||
|
{% if let Some(fp_id) = brew.filter_paper_id %}
|
||||||
|
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
|
||||||
|
{% endif %}
|
||||||
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
|
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
|
||||||
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
|
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@
|
||||||
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
||||||
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting }}" />
|
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting }}" />
|
||||||
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
||||||
|
{% if let Some(fp_id) = brew.filter_paper_id %}
|
||||||
|
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
|
||||||
|
{% endif %}
|
||||||
<input type="hidden" name="water_volume" value="{{ brew.water_volume }}" />
|
<input type="hidden" name="water_volume" value="{{ brew.water_volume }}" />
|
||||||
<input type="hidden" name="water_temp" value="{{ brew.water_temp }}" />
|
<input type="hidden" name="water_temp" value="{{ brew.water_temp }}" />
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue