brewlog/src/presentation/web/views/brews.rs
Jon Seager 472e851b80
refactor: standardize view model conversions on From trait
Convert single-argument from_domain() methods to impl From<T> for
consistency with the existing CafeView, RoasterView, and option view
patterns. Affected: BagView, BrewView, CupView, GearView,
GearDetailView, CafeDetailView, RoasterDetailView, TimelineEventView.

RoastView retains from_domain() since it takes extra parameters.
2026-02-13 16:20:27 +00:00

313 lines
10 KiB
Rust

use std::fmt::Write;
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time};
use crate::domain::formatting::format_weight;
use crate::domain::roasters::Roaster;
use crate::domain::roasts::Roast;
use super::tasting_notes::TastingNoteView;
use super::{
LegendEntry, build_coffee_info, build_origin_roaster_map, build_roaster_info, format_datetime,
relative_date,
};
#[derive(Clone)]
pub struct QuickNoteView {
pub label: String,
pub pill_class: &'static str,
pub form_value: String,
}
impl From<QuickNote> for QuickNoteView {
fn from(note: QuickNote) -> Self {
Self {
label: note.label().to_string(),
pill_class: if note.is_positive() {
"pill pill-success"
} else {
"pill pill-warning"
},
form_value: note.form_value().to_string(),
}
}
}
#[derive(Clone)]
pub struct BrewView {
pub id: String,
pub bag_id: i64,
pub roast_name: String,
pub roaster_name: String,
pub roast_slug: String,
pub roaster_slug: String,
pub coffee_weight: String,
pub grinder_id: i64,
pub grinder_name: String,
pub grinder_model: String,
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,
pub brew_time: Option<String>,
pub quick_notes: Vec<QuickNoteView>,
pub quick_notes_label: String,
pub quick_notes_raw: String,
pub created_date: String,
pub created_time: String,
pub relative_date_label: String,
// Raw values for "brew again" feature
pub coffee_weight_raw: f64,
pub grind_setting_raw: f64,
pub water_volume_raw: i32,
pub water_temp_raw: f64,
pub brew_time_raw: Option<i32>,
}
impl From<BrewWithDetails> for BrewView {
fn from(brew: BrewWithDetails) -> Self {
let ratio = if brew.brew.coffee_weight > 0.0 {
format!(
"1:{:.1}",
f64::from(brew.brew.water_volume) / brew.brew.coffee_weight
)
} else {
"\u{2014}".to_string()
};
let quick_notes: Vec<QuickNoteView> = brew
.brew
.quick_notes
.iter()
.copied()
.map(QuickNoteView::from)
.collect();
let quick_notes_label = quick_notes
.iter()
.map(|n| n.label.as_str())
.collect::<Vec<_>>()
.join(", ");
let quick_notes_raw = quick_notes
.iter()
.map(|n| n.form_value.as_str())
.collect::<Vec<_>>()
.join(",");
let (created_date, created_time) = format_datetime(brew.brew.created_at);
Self {
id: brew.brew.id.to_string(),
bag_id: brew.brew.bag_id.into_inner(),
roast_name: brew.roast_name,
roaster_name: brew.roaster_name,
roast_slug: brew.roast_slug,
roaster_slug: brew.roaster_slug,
coffee_weight: format_weight(brew.brew.coffee_weight),
grinder_id: brew.brew.grinder_id.into_inner(),
grinder_model: brew.grinder_model,
grinder_name: brew.grinder_name,
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,
brew_time: brew.brew.brew_time.map(format_brew_time),
quick_notes,
quick_notes_label,
quick_notes_raw,
created_date,
created_time,
relative_date_label: relative_date(brew.brew.created_at),
coffee_weight_raw: brew.brew.coffee_weight,
grind_setting_raw: brew.brew.grind_setting,
water_volume_raw: brew.brew.water_volume,
water_temp_raw: brew.brew.water_temp,
brew_time_raw: brew.brew.brew_time,
}
}
}
impl BrewView {
/// Build a URL to the add-brew form pre-filled with this brew's parameters.
pub fn brew_again_url(&self) -> String {
let mut url = format!(
"/add?type=brew&bag_id={}&coffee_weight={}&grinder_id={}&grind_setting={}&brewer_id={}&water_volume={}&water_temp={}",
self.bag_id,
self.coffee_weight_raw,
self.grinder_id,
self.grind_setting_raw,
self.brewer_id,
self.water_volume_raw,
self.water_temp_raw,
);
if let Some(fp_id) = self.filter_paper_id {
let _ = write!(url, "&filter_paper_id={fp_id}");
}
if let Some(bt) = self.brew_time_raw {
let _ = write!(url, "&brew_time={bt}");
}
if !self.quick_notes_raw.is_empty() {
let _ = write!(url, "&quick_notes={}", self.quick_notes_raw);
}
url
}
}
pub struct BrewDefaultsView {
pub bag_id: String,
pub grinder_id: String,
pub grinder_name: String,
pub brewer_id: String,
pub brewer_name: String,
pub filter_paper_id: String,
pub filter_paper_name: String,
pub coffee_weight: f64,
pub grind_setting: f64,
pub water_volume: i32,
pub water_temp: f64,
pub brew_time: Option<i32>,
/// Comma-separated quick note form values (e.g. "good,too-fast") for pre-filling toggles.
pub quick_notes_raw: String,
}
impl Default for BrewDefaultsView {
fn default() -> Self {
Self {
bag_id: String::new(),
grinder_id: String::new(),
grinder_name: String::new(),
brewer_id: String::new(),
brewer_name: String::new(),
filter_paper_id: String::new(),
filter_paper_name: String::new(),
coffee_weight: 15.0,
grind_setting: 6.0,
water_volume: 250,
water_temp: 91.0,
brew_time: Some(120),
quick_notes_raw: String::new(),
}
}
}
pub struct BrewDetailView {
pub id: String,
// Coffee info
pub roast_name: String,
pub roaster_name: String,
pub origin: String,
pub origin_flag: String,
pub region: String,
pub producer: String,
pub process: String,
pub tasting_notes: Vec<TastingNoteView>,
// Roaster info
pub roaster_country: String,
pub roaster_country_flag: String,
pub roaster_city: Option<String>,
pub roaster_homepage: Option<String>,
// Recipe
pub coffee_weight: String,
pub water_volume: String,
pub water_temp: String,
pub grind_setting: String,
pub brew_time: Option<String>,
pub quick_notes_label: String,
// Gear
pub grinder_name: String,
pub brewer_name: String,
pub filter_paper_name: Option<String>,
// Map
pub map_countries: String,
pub map_max: u32,
pub legend_entries: Vec<LegendEntry>,
// Slugs (for breadcrumbs)
pub roaster_slug: String,
pub roast_slug: String,
// Dates
pub created_date: String,
pub created_time: String,
}
impl BrewDetailView {
pub fn from_parts(brew: BrewWithDetails, roast: &Roast, roaster: &Roaster) -> Self {
let coffee = build_coffee_info(roast);
let roaster_info = build_roaster_info(roaster);
let (map_countries, map_max, legend_entries) =
build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
let (created_date, created_time) = format_datetime(brew.brew.created_at);
let quick_notes_label = brew
.brew
.quick_notes
.iter()
.map(|n| n.label())
.collect::<Vec<_>>()
.join(", ");
Self {
id: brew.brew.id.to_string(),
roast_name: brew.roast_name,
roaster_name: brew.roaster_name,
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
origin: coffee.origin,
origin_flag: coffee.origin_flag,
region: coffee.region,
producer: coffee.producer,
process: coffee.process,
tasting_notes: coffee.tasting_notes,
roaster_country: roaster_info.country,
roaster_country_flag: roaster_info.country_flag,
roaster_city: roaster_info.city,
roaster_homepage: roaster_info.homepage,
coffee_weight: format_weight(brew.brew.coffee_weight),
water_volume: format!("{}ml", brew.brew.water_volume),
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
grind_setting: format!("{:.1}", brew.brew.grind_setting),
brew_time: brew.brew.brew_time.map(format_brew_time),
quick_notes_label,
grinder_name: brew.grinder_name,
brewer_name: brew.brewer_name,
filter_paper_name: brew.filter_paper_name,
map_countries,
map_max,
legend_entries,
created_date,
created_time,
}
}
}
impl From<BrewWithDetails> for BrewDefaultsView {
fn from(brew: BrewWithDetails) -> Self {
Self {
bag_id: brew.brew.bag_id.to_string(),
grinder_id: brew.brew.grinder_id.to_string(),
grinder_name: brew.grinder_name,
brewer_id: brew.brew.brewer_id.to_string(),
brewer_name: brew.brewer_name,
filter_paper_id: brew
.brew
.filter_paper_id
.map(|id| id.to_string())
.unwrap_or_default(),
filter_paper_name: brew.filter_paper_name.unwrap_or_default(),
coffee_weight: brew.brew.coffee_weight,
grind_setting: brew.brew.grind_setting,
water_volume: brew.brew.water_volume,
water_temp: brew.brew.water_temp,
brew_time: brew.brew.brew_time,
quick_notes_raw: String::new(),
}
}
}