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:
Jon Seager 2026-02-03 10:14:22 +00:00
parent d636f6c246
commit 8b77eb90eb
No known key found for this signature in database
8 changed files with 127 additions and 34 deletions

View file

@ -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,

View file

@ -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,
) )

View file

@ -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>,
} }

View file

@ -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,

View file

@ -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>

View file

@ -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">

View file

@ -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

View file

@ -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