refactor: extract duplicated helpers for tasting notes, used_percent, format_datetime, and map builder

- Add parse_and_categorize() in tasting_notes.rs, replacing identical
  split-trim-categorize pipelines in mod.rs, roasts.rs, and timeline.rs
- Add used_percent() in bags.rs, deduplicating the calculation in
  BagView::from_domain and BagDetailView::from_parts
- Add format_datetime() in views/mod.rs, replacing 16 paired occurrences
  of .format("%Y-%m-%d") / .format("%H:%M") across 7 view files
- Add build_origin_roaster_map() in views/mod.rs, replacing identical
  map-entry building blocks in bags.rs, brews.rs, and roasts.rs detail views
This commit is contained in:
Jon Seager 2026-02-13 15:09:32 +00:00
parent 3d49236c13
commit 766ac1432d
No known key found for this signature in database
10 changed files with 125 additions and 132 deletions

View file

@ -4,7 +4,17 @@ use crate::domain::roasters::Roaster;
use crate::domain::roasts::Roast; use crate::domain::roasts::Roast;
use super::tasting_notes::TastingNoteView; use super::tasting_notes::TastingNoteView;
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info}; use super::{
LegendEntry, build_coffee_info, build_origin_roaster_map, build_roaster_info, format_datetime,
};
fn used_percent(amount: f64, remaining: f64) -> u8 {
if amount > 0.0 {
(((amount - remaining) / amount) * 100.0).clamp(0.0, 100.0) as u8
} else {
0
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BagView { pub struct BagView {
@ -26,12 +36,8 @@ pub struct BagView {
impl BagView { impl BagView {
pub fn from_domain(bag: BagWithRoast) -> Self { pub fn from_domain(bag: BagWithRoast) -> Self {
let used_percent = if bag.bag.amount > 0.0 { let used_percent = used_percent(bag.bag.amount, bag.bag.remaining);
(((bag.bag.amount - bag.bag.remaining) / bag.bag.amount) * 100.0).clamp(0.0, 100.0) let (created_date, created_time) = format_datetime(bag.bag.created_at);
as u8
} else {
0
};
Self { Self {
id: bag.bag.id.to_string(), id: bag.bag.id.to_string(),
roast_id: bag.bag.roast_id.to_string(), roast_id: bag.bag.roast_id.to_string(),
@ -43,8 +49,8 @@ impl BagView {
.bag .bag
.finished_at .finished_at
.map_or_else(|| "".to_string(), |d| d.format("%Y-%m-%d").to_string()), .map_or_else(|| "".to_string(), |d| d.format("%Y-%m-%d").to_string()),
created_date: bag.bag.created_at.format("%Y-%m-%d").to_string(), created_date,
created_time: bag.bag.created_at.format("%H:%M").to_string(), created_time,
roast_name: bag.roast_name, roast_name: bag.roast_name,
roaster_name: bag.roaster_name, roaster_name: bag.roaster_name,
roast_slug: bag.roast_slug, roast_slug: bag.roast_slug,
@ -103,14 +109,9 @@ impl BagDetailView {
let coffee = build_coffee_info(roast); let coffee = build_coffee_info(roast);
let roaster_info = build_roaster_info(roaster); let roaster_info = build_roaster_info(roaster);
let mut map_entries: Vec<(&str, u32)> = Vec::new(); let (map_countries, map_max, legend_entries) =
if let Some(ref o) = roast.origin build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
&& !o.is_empty() let (created_date, created_time) = format_datetime(bag.bag.created_at);
{
map_entries.push((o.as_str(), 2));
}
map_entries.push((roaster.country.as_str(), 1));
let (map_countries, map_max) = build_map_data(&map_entries);
Self { Self {
id: bag.bag.id.to_string(), id: bag.bag.id.to_string(),
@ -130,12 +131,7 @@ impl BagDetailView {
roaster_homepage: roaster_info.homepage, roaster_homepage: roaster_info.homepage,
amount: format_weight(bag.bag.amount), amount: format_weight(bag.bag.amount),
remaining: format_weight(bag.bag.remaining), remaining: format_weight(bag.bag.remaining),
used_percent: if bag.bag.amount > 0.0 { used_percent: used_percent(bag.bag.amount, bag.bag.remaining),
(((bag.bag.amount - bag.bag.remaining) / bag.bag.amount) * 100.0).clamp(0.0, 100.0)
as u8
} else {
0
},
closed: bag.bag.closed, closed: bag.bag.closed,
roast_date: bag.bag.roast_date.map(|d| d.to_string()), roast_date: bag.bag.roast_date.map(|d| d.to_string()),
finished_date: bag finished_date: bag
@ -144,18 +140,9 @@ impl BagDetailView {
.map(|d| d.format("%Y-%m-%d").to_string()), .map(|d| d.format("%Y-%m-%d").to_string()),
map_countries, map_countries,
map_max, map_max,
legend_entries: vec![ legend_entries,
LegendEntry { created_date,
label: "Origin", created_time,
opacity: "",
},
LegendEntry {
label: "Roaster",
opacity: "opacity-50",
},
],
created_date: bag.bag.created_at.format("%Y-%m-%d").to_string(),
created_time: bag.bag.created_at.format("%H:%M").to_string(),
} }
} }
} }

View file

@ -6,7 +6,10 @@ use crate::domain::roasters::Roaster;
use crate::domain::roasts::Roast; use crate::domain::roasts::Roast;
use super::tasting_notes::TastingNoteView; use super::tasting_notes::TastingNoteView;
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info, relative_date}; use super::{
LegendEntry, build_coffee_info, build_origin_roaster_map, build_roaster_info, format_datetime,
relative_date,
};
#[derive(Clone)] #[derive(Clone)]
pub struct QuickNoteView { pub struct QuickNoteView {
@ -92,6 +95,7 @@ impl BrewView {
.map(|n| n.form_value.as_str()) .map(|n| n.form_value.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","); .join(",");
let (created_date, created_time) = format_datetime(brew.brew.created_at);
Self { Self {
id: brew.brew.id.to_string(), id: brew.brew.id.to_string(),
@ -119,8 +123,8 @@ impl BrewView {
quick_notes, quick_notes,
quick_notes_label, quick_notes_label,
quick_notes_raw, quick_notes_raw,
created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(), created_date,
created_time: brew.brew.created_at.format("%H:%M").to_string(), created_time,
relative_date_label: relative_date(brew.brew.created_at), relative_date_label: relative_date(brew.brew.created_at),
coffee_weight_raw: brew.brew.coffee_weight, coffee_weight_raw: brew.brew.coffee_weight,
grind_setting_raw: brew.brew.grind_setting, grind_setting_raw: brew.brew.grind_setting,
@ -236,14 +240,9 @@ impl BrewDetailView {
let coffee = build_coffee_info(roast); let coffee = build_coffee_info(roast);
let roaster_info = build_roaster_info(roaster); let roaster_info = build_roaster_info(roaster);
let mut map_entries: Vec<(&str, u32)> = Vec::new(); let (map_countries, map_max, legend_entries) =
if let Some(ref o) = roast.origin build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
&& !o.is_empty() let (created_date, created_time) = format_datetime(brew.brew.created_at);
{
map_entries.push((o.as_str(), 2));
}
map_entries.push((roaster.country.as_str(), 1));
let (map_countries, map_max) = build_map_data(&map_entries);
let quick_notes_label = brew let quick_notes_label = brew
.brew .brew
@ -280,18 +279,9 @@ impl BrewDetailView {
filter_paper_name: brew.filter_paper_name, filter_paper_name: brew.filter_paper_name,
map_countries, map_countries,
map_max, map_max,
legend_entries: vec![ legend_entries,
LegendEntry { created_date,
label: "Origin", created_time,
opacity: "",
},
LegendEntry {
label: "Roaster",
opacity: "opacity-50",
},
],
created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(),
created_time: brew.brew.created_at.format("%H:%M").to_string(),
} }
} }
} }

View file

@ -2,7 +2,7 @@ use crate::domain::cafes::Cafe;
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
use crate::domain::nearby_cafes::NearbyCafeResult; use crate::domain::nearby_cafes::NearbyCafeResult;
use super::{LegendEntry, build_map_data}; use super::{LegendEntry, build_map_data, format_datetime};
pub struct CafeDetailView { pub struct CafeDetailView {
pub id: String, pub id: String,
@ -21,6 +21,7 @@ pub struct CafeDetailView {
impl CafeDetailView { impl CafeDetailView {
pub fn from_domain(cafe: Cafe) -> Self { pub fn from_domain(cafe: Cafe) -> Self {
let (created_date, created_time) = format_datetime(cafe.created_at);
let country_flag = country_to_iso(&cafe.country) let country_flag = country_to_iso(&cafe.country)
.map(iso_to_flag_emoji) .map(iso_to_flag_emoji)
.unwrap_or_default(); .unwrap_or_default();
@ -44,8 +45,8 @@ impl CafeDetailView {
label: "Cafe", label: "Cafe",
opacity: "", opacity: "",
}], }],
created_date: cafe.created_at.format("%Y-%m-%d").to_string(), created_date,
created_time: cafe.created_at.format("%H:%M").to_string(), created_time,
} }
} }
} }
@ -92,8 +93,7 @@ impl From<Cafe> for CafeView {
.unwrap_or_default(); .unwrap_or_default();
let created_at_sort_key = created_at.timestamp(); let created_at_sort_key = created_at.timestamp();
let created_date = created_at.format("%Y-%m-%d").to_string(); let (created_date, created_time) = format_datetime(created_at);
let created_time = created_at.format("%H:%M").to_string();
Self { Self {
detail_path, detail_path,

View file

@ -5,7 +5,7 @@ use crate::domain::roasters::Roaster;
use crate::domain::roasts::Roast; use crate::domain::roasts::Roast;
use super::tasting_notes::TastingNoteView; use super::tasting_notes::TastingNoteView;
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info}; use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info, format_datetime};
#[derive(Clone)] #[derive(Clone)]
pub struct CupView { pub struct CupView {
@ -23,6 +23,7 @@ pub struct CupView {
impl CupView { impl CupView {
pub fn from_domain(cup: CupWithDetails) -> Self { pub fn from_domain(cup: CupWithDetails) -> Self {
let (created_date, created_time) = format_datetime(cup.cup.created_at);
Self { Self {
id: cup.cup.id.to_string(), id: cup.cup.id.to_string(),
roast_name: cup.roast_name, roast_name: cup.roast_name,
@ -32,8 +33,8 @@ impl CupView {
cafe_name: cup.cafe_name, cafe_name: cup.cafe_name,
cafe_slug: cup.cafe_slug, cafe_slug: cup.cafe_slug,
cafe_city: cup.cafe_city, cafe_city: cup.cafe_city,
created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(), created_date,
created_time: cup.cup.created_at.format("%H:%M").to_string(), created_time,
} }
} }
} }
@ -92,6 +93,7 @@ impl CupDetailView {
} }
map_entries.push((roaster.country.as_str(), 1)); map_entries.push((roaster.country.as_str(), 1));
let (map_countries, map_max) = build_map_data(&map_entries); let (map_countries, map_max) = build_map_data(&map_entries);
let (created_date, created_time) = format_datetime(cup.cup.created_at);
Self { Self {
id: cup.cup.id.to_string(), id: cup.cup.id.to_string(),
@ -135,8 +137,8 @@ impl CupDetailView {
opacity: "opacity-35", opacity: "opacity-35",
}, },
], ],
created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(), created_date,
created_time: cup.cup.created_at.format("%H:%M").to_string(), created_time,
} }
} }
} }

View file

@ -1,5 +1,7 @@
use crate::domain::gear::Gear; use crate::domain::gear::Gear;
use super::format_datetime;
pub struct GearDetailView { pub struct GearDetailView {
pub id: String, pub id: String,
pub category_label: String, pub category_label: String,
@ -11,13 +13,14 @@ pub struct GearDetailView {
impl GearDetailView { impl GearDetailView {
pub fn from_domain(gear: Gear) -> Self { pub fn from_domain(gear: Gear) -> Self {
let (created_date, created_time) = format_datetime(gear.created_at);
Self { Self {
id: gear.id.to_string(), id: gear.id.to_string(),
category_label: gear.category.display_label().to_string(), category_label: gear.category.display_label().to_string(),
make: gear.make, make: gear.make,
model: gear.model, model: gear.model,
created_date: gear.created_at.format("%Y-%m-%d").to_string(), created_date,
created_time: gear.created_at.format("%H:%M").to_string(), created_time,
} }
} }
} }
@ -36,6 +39,7 @@ pub struct GearView {
impl GearView { impl GearView {
pub fn from_domain(gear: Gear) -> Self { pub fn from_domain(gear: Gear) -> Self {
let (created_date, created_time) = format_datetime(gear.created_at);
Self { Self {
id: gear.id.to_string(), id: gear.id.to_string(),
category: gear.category.as_str().to_string(), category: gear.category.as_str().to_string(),
@ -43,8 +47,8 @@ impl GearView {
make: gear.make.clone(), make: gear.make.clone(),
model: gear.model.clone(), model: gear.model.clone(),
full_name: format!("{} {}", gear.make, gear.model), full_name: format!("{} {}", gear.make, gear.model),
created_date: gear.created_at.format("%Y-%m-%d").to_string(), created_date,
created_time: gear.created_at.format("%H:%M").to_string(), created_time,
} }
} }
} }

View file

@ -55,6 +55,13 @@ fn relative_date(dt: DateTime<Utc>) -> String {
crate::domain::formatting::format_relative_time(dt, Utc::now()) crate::domain::formatting::format_relative_time(dt, Utc::now())
} }
pub(crate) fn format_datetime(dt: DateTime<Utc>) -> (String, String) {
(
dt.format("%Y-%m-%d").to_string(),
dt.format("%H:%M").to_string(),
)
}
pub struct Paginated<T> { pub struct Paginated<T> {
pub items: Vec<T>, pub items: Vec<T>,
pub page: u32, pub page: u32,
@ -388,17 +395,7 @@ pub(crate) fn build_coffee_info(roast: &crate::domain::roasts::Roast) -> CoffeeI
.map(iso_to_flag_emoji) .map(iso_to_flag_emoji)
.unwrap_or_default(); .unwrap_or_default();
let notes = roast let notes = tasting_notes::parse_and_categorize(&roast.tasting_notes);
.tasting_notes
.iter()
.flat_map(|note| {
note.split([',', '\n'])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
})
.map(|n| tasting_notes::categorize(&n))
.collect();
CoffeeInfo { CoffeeInfo {
origin: if origin.is_empty() { origin: if origin.is_empty() {
@ -450,6 +447,36 @@ pub(crate) fn build_roaster_info(roaster: &crate::domain::roasters::Roaster) ->
} }
} }
/// Build origin + roaster country map data with standard legend entries.
///
/// Origin gets weight 2, roaster country gets weight 1. Returns the
/// `(data-countries, data-max, legend_entries)` tuple used by detail pages.
pub(crate) fn build_origin_roaster_map(
origin: Option<&str>,
roaster_country: &str,
) -> (String, u32, Vec<LegendEntry>) {
let mut entries: Vec<(&str, u32)> = Vec::new();
if let Some(o) = origin.filter(|o| !o.is_empty()) {
entries.push((o, 2));
}
entries.push((roaster_country, 1));
let (map_countries, map_max) = build_map_data(&entries);
(
map_countries,
map_max,
vec![
LegendEntry {
label: "Origin",
opacity: "",
},
LegendEntry {
label: "Roaster",
opacity: "opacity-50",
},
],
)
}
/// Build `data-countries` and `data-max` values for the world-map component. /// Build `data-countries` and `data-max` values for the world-map component.
/// ///
/// Accepts `(country_name, weight)` pairs where higher weights render darker. /// Accepts `(country_name, weight)` pairs where higher weights render darker.

View file

@ -1,7 +1,7 @@
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
use crate::domain::roasters::Roaster; use crate::domain::roasters::Roaster;
use super::{LegendEntry, build_map_data}; use super::{LegendEntry, build_map_data, format_datetime};
pub struct RoasterDetailView { pub struct RoasterDetailView {
pub id: String, pub id: String,
@ -23,6 +23,7 @@ impl RoasterDetailView {
.map(iso_to_flag_emoji) .map(iso_to_flag_emoji)
.unwrap_or_default(); .unwrap_or_default();
let (map_countries, map_max) = build_map_data(&[(&roaster.country, 1)]); let (map_countries, map_max) = build_map_data(&[(&roaster.country, 1)]);
let (created_date, created_time) = format_datetime(roaster.created_at);
Self { Self {
id: roaster.id.to_string(), id: roaster.id.to_string(),
@ -37,8 +38,8 @@ impl RoasterDetailView {
label: "Roaster", label: "Roaster",
opacity: "", opacity: "",
}], }],
created_date: roaster.created_at.format("%Y-%m-%d").to_string(), created_date,
created_time: roaster.created_at.format("%H:%M").to_string(), created_time,
} }
} }
} }
@ -101,8 +102,7 @@ impl From<Roaster> for RoasterView {
.unwrap_or_default(); .unwrap_or_default();
let created_at_sort_key = created_at.timestamp(); let created_at_sort_key = created_at.timestamp();
let created_date = created_at.format("%Y-%m-%d").to_string(); let (created_date, created_time) = format_datetime(created_at);
let created_time = created_at.format("%H:%M").to_string();
Self { Self {
detail_path, detail_path,

View file

@ -3,7 +3,9 @@ use crate::domain::roasters::Roaster;
use crate::domain::roasts::{Roast, RoastWithRoaster}; use crate::domain::roasts::{Roast, RoastWithRoaster};
use super::tasting_notes::{self, TastingNoteView}; use super::tasting_notes::{self, TastingNoteView};
use super::{LegendEntry, build_coffee_info, build_map_data, build_roaster_info}; use super::{
LegendEntry, build_coffee_info, build_origin_roaster_map, build_roaster_info, format_datetime,
};
pub struct RoastView { pub struct RoastView {
pub id: String, pub id: String,
@ -67,18 +69,8 @@ impl RoastView {
let producer = producer.unwrap_or_else(|| "".to_string()); let producer = producer.unwrap_or_else(|| "".to_string());
let process = process.unwrap_or_else(|| "".to_string()); let process = process.unwrap_or_else(|| "".to_string());
let created_at_sort_key = created_at.timestamp(); let created_at_sort_key = created_at.timestamp();
let tasting_notes = tasting_notes let tasting_notes = tasting_notes::parse_and_categorize(&tasting_notes);
.into_iter() let (created_date, created_time) = format_datetime(created_at);
.flat_map(|note| {
note.split([',', '\n'])
.map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
})
.map(|note| tasting_notes::categorize(&note))
.collect();
let created_date = created_at.format("%Y-%m-%d").to_string();
let created_time = created_at.format("%H:%M").to_string();
let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}"); let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}");
Self { Self {
@ -131,14 +123,9 @@ impl RoastDetailView {
let coffee = build_coffee_info(&roast); let coffee = build_coffee_info(&roast);
let roaster_info = build_roaster_info(roaster); let roaster_info = build_roaster_info(roaster);
let mut map_entries: Vec<(&str, u32)> = Vec::new(); let (map_countries, map_max, legend_entries) =
if let Some(ref o) = roast.origin build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
&& !o.is_empty() let (created_date, created_time) = format_datetime(roast.created_at);
{
map_entries.push((o.as_str(), 2));
}
map_entries.push((roaster.country.as_str(), 1));
let (map_countries, map_max) = build_map_data(&map_entries);
Self { Self {
id: roast.id.to_string(), id: roast.id.to_string(),
@ -157,18 +144,9 @@ impl RoastDetailView {
roaster_homepage: roaster_info.homepage, roaster_homepage: roaster_info.homepage,
map_countries, map_countries,
map_max, map_max,
legend_entries: vec![ legend_entries,
LegendEntry { created_date,
label: "Origin", created_time,
opacity: "",
},
LegendEntry {
label: "Roaster",
opacity: "opacity-50",
},
],
created_date: roast.created_at.format("%Y-%m-%d").to_string(),
created_time: roast.created_at.format("%H:%M").to_string(),
} }
} }
} }

View file

@ -54,6 +54,21 @@ pub fn categorize(note: &str) -> TastingNoteView {
} }
} }
/// Split raw tasting note strings on commas and newlines, trim whitespace,
/// drop empties, and categorise each resulting segment.
pub fn parse_and_categorize(notes: &[String]) -> Vec<TastingNoteView> {
notes
.iter()
.flat_map(|note| {
note.split([',', '\n'])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
})
.map(|n| categorize(&n))
.collect()
}
// ── Exact matches ──────────────────────────────────────────────────── // ── Exact matches ────────────────────────────────────────────────────
fn exact_match(lower: &str) -> Option<NoteCategory> { fn exact_match(lower: &str) -> Option<NoteCategory> {

View file

@ -125,17 +125,7 @@ impl TimelineEventView {
Self::add_country_flags(&mut mapped_details); Self::add_country_flags(&mut mapped_details);
let tasting_notes = if entity_type == EntityType::Roast { let tasting_notes = if entity_type == EntityType::Roast {
let notes = tasting_notes Some(tasting_notes::parse_and_categorize(&tasting_notes))
.into_iter()
.flat_map(|note| {
note.split([',', '\n'])
.map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
})
.map(|note| tasting_notes::categorize(&note))
.collect::<Vec<_>>();
Some(notes)
} else { } else {
None None
}; };