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::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<GearOptionView> = 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<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)]
|
||||
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<GearId>,
|
||||
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,13 +289,7 @@ fn brew_timeline_event(enriched: &BrewWithDetails) -> NewTimelineEvent {
|
|||
"N/A".to_string()
|
||||
};
|
||||
|
||||
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![
|
||||
let mut details = vec![
|
||||
TimelineEventDetail {
|
||||
label: "Roaster".to_string(),
|
||||
value: enriched.roaster_name.clone(),
|
||||
|
|
@ -281,11 +316,27 @@ fn brew_timeline_event(enriched: &BrewWithDetails) -> NewTimelineEvent {
|
|||
label: "Brewer".to_string(),
|
||||
value: enriched.brewer_name.clone(),
|
||||
},
|
||||
TimelineEventDetail {
|
||||
];
|
||||
|
||||
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,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<i64>,
|
||||
|
||||
/// 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ pub struct BrewsTemplate {
|
|||
pub bag_options: Vec<BagOptionView>,
|
||||
pub grinder_options: Vec<GearOptionView>,
|
||||
pub brewer_options: Vec<GearOptionView>,
|
||||
pub filter_paper_options: Vec<GearOptionView>,
|
||||
pub navigator: ListNavigator<BrewSortKey>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ pub struct TimelineBrewDataView {
|
|||
pub bag_id: i64,
|
||||
pub grinder_id: i64,
|
||||
pub brewer_id: i64,
|
||||
pub filter_paper_id: Option<i64>,
|
||||
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<i64>,
|
||||
pub filter_paper_name: Option<String>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,17 @@
|
|||
</select>
|
||||
</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 -->
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Coffee (g) *</span>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
<option value="">Select a category</option>
|
||||
<option value="grinder">Grinder</option>
|
||||
<option value="brewer">Brewer</option>
|
||||
<option value="filter_paper">Filter Paper</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
</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>
|
||||
{% if is_authenticated %}
|
||||
<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="grind_setting" value="{{ brew.grind_setting_raw }}" />
|
||||
<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_temp" value="{{ brew.water_temp_raw }}" />
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@
|
|||
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
||||
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting }}" />
|
||||
<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_temp" value="{{ brew.water_temp }}" />
|
||||
<button
|
||||
|
|
|
|||
Loading…
Reference in a new issue