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 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)]
pub struct BagView {
@ -26,12 +36,8 @@ pub struct BagView {
impl BagView {
pub fn from_domain(bag: BagWithRoast) -> Self {
let used_percent = if bag.bag.amount > 0.0 {
(((bag.bag.amount - bag.bag.remaining) / bag.bag.amount) * 100.0).clamp(0.0, 100.0)
as u8
} else {
0
};
let used_percent = used_percent(bag.bag.amount, bag.bag.remaining);
let (created_date, created_time) = format_datetime(bag.bag.created_at);
Self {
id: bag.bag.id.to_string(),
roast_id: bag.bag.roast_id.to_string(),
@ -43,8 +49,8 @@ impl BagView {
.bag
.finished_at
.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_time: bag.bag.created_at.format("%H:%M").to_string(),
created_date,
created_time,
roast_name: bag.roast_name,
roaster_name: bag.roaster_name,
roast_slug: bag.roast_slug,
@ -103,14 +109,9 @@ impl BagDetailView {
let coffee = build_coffee_info(roast);
let roaster_info = build_roaster_info(roaster);
let mut map_entries: Vec<(&str, u32)> = Vec::new();
if let Some(ref o) = roast.origin
&& !o.is_empty()
{
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 (map_countries, map_max, legend_entries) =
build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
let (created_date, created_time) = format_datetime(bag.bag.created_at);
Self {
id: bag.bag.id.to_string(),
@ -130,12 +131,7 @@ impl BagDetailView {
roaster_homepage: roaster_info.homepage,
amount: format_weight(bag.bag.amount),
remaining: format_weight(bag.bag.remaining),
used_percent: if bag.bag.amount > 0.0 {
(((bag.bag.amount - bag.bag.remaining) / bag.bag.amount) * 100.0).clamp(0.0, 100.0)
as u8
} else {
0
},
used_percent: used_percent(bag.bag.amount, bag.bag.remaining),
closed: bag.bag.closed,
roast_date: bag.bag.roast_date.map(|d| d.to_string()),
finished_date: bag
@ -144,18 +140,9 @@ impl BagDetailView {
.map(|d| d.format("%Y-%m-%d").to_string()),
map_countries,
map_max,
legend_entries: vec![
LegendEntry {
label: "Origin",
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(),
legend_entries,
created_date,
created_time,
}
}
}

View file

@ -6,7 +6,10 @@ use crate::domain::roasters::Roaster;
use crate::domain::roasts::Roast;
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)]
pub struct QuickNoteView {
@ -92,6 +95,7 @@ impl BrewView {
.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(),
@ -119,8 +123,8 @@ impl BrewView {
quick_notes,
quick_notes_label,
quick_notes_raw,
created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(),
created_time: brew.brew.created_at.format("%H:%M").to_string(),
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,
@ -236,14 +240,9 @@ impl BrewDetailView {
let coffee = build_coffee_info(roast);
let roaster_info = build_roaster_info(roaster);
let mut map_entries: Vec<(&str, u32)> = Vec::new();
if let Some(ref o) = roast.origin
&& !o.is_empty()
{
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 (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
@ -280,18 +279,9 @@ impl BrewDetailView {
filter_paper_name: brew.filter_paper_name,
map_countries,
map_max,
legend_entries: vec![
LegendEntry {
label: "Origin",
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(),
legend_entries,
created_date,
created_time,
}
}
}

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

View file

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

View file

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

View file

@ -55,6 +55,13 @@ fn relative_date(dt: DateTime<Utc>) -> String {
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 items: Vec<T>,
pub page: u32,
@ -388,17 +395,7 @@ pub(crate) fn build_coffee_info(roast: &crate::domain::roasts::Roast) -> CoffeeI
.map(iso_to_flag_emoji)
.unwrap_or_default();
let notes = roast
.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();
let notes = tasting_notes::parse_and_categorize(&roast.tasting_notes);
CoffeeInfo {
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.
///
/// 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::roasters::Roaster;
use super::{LegendEntry, build_map_data};
use super::{LegendEntry, build_map_data, format_datetime};
pub struct RoasterDetailView {
pub id: String,
@ -23,6 +23,7 @@ impl RoasterDetailView {
.map(iso_to_flag_emoji)
.unwrap_or_default();
let (map_countries, map_max) = build_map_data(&[(&roaster.country, 1)]);
let (created_date, created_time) = format_datetime(roaster.created_at);
Self {
id: roaster.id.to_string(),
@ -37,8 +38,8 @@ impl RoasterDetailView {
label: "Roaster",
opacity: "",
}],
created_date: roaster.created_at.format("%Y-%m-%d").to_string(),
created_time: roaster.created_at.format("%H:%M").to_string(),
created_date,
created_time,
}
}
}
@ -101,8 +102,7 @@ impl From<Roaster> for RoasterView {
.unwrap_or_default();
let created_at_sort_key = created_at.timestamp();
let created_date = created_at.format("%Y-%m-%d").to_string();
let created_time = created_at.format("%H:%M").to_string();
let (created_date, created_time) = format_datetime(created_at);
Self {
detail_path,

View file

@ -3,7 +3,9 @@ use crate::domain::roasters::Roaster;
use crate::domain::roasts::{Roast, RoastWithRoaster};
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 id: String,
@ -67,18 +69,8 @@ impl RoastView {
let producer = producer.unwrap_or_else(|| "".to_string());
let process = process.unwrap_or_else(|| "".to_string());
let created_at_sort_key = created_at.timestamp();
let tasting_notes = 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();
let created_date = created_at.format("%Y-%m-%d").to_string();
let created_time = created_at.format("%H:%M").to_string();
let tasting_notes = tasting_notes::parse_and_categorize(&tasting_notes);
let (created_date, created_time) = format_datetime(created_at);
let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}");
Self {
@ -131,14 +123,9 @@ impl RoastDetailView {
let coffee = build_coffee_info(&roast);
let roaster_info = build_roaster_info(roaster);
let mut map_entries: Vec<(&str, u32)> = Vec::new();
if let Some(ref o) = roast.origin
&& !o.is_empty()
{
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 (map_countries, map_max, legend_entries) =
build_origin_roaster_map(roast.origin.as_deref(), &roaster.country);
let (created_date, created_time) = format_datetime(roast.created_at);
Self {
id: roast.id.to_string(),
@ -157,18 +144,9 @@ impl RoastDetailView {
roaster_homepage: roaster_info.homepage,
map_countries,
map_max,
legend_entries: vec![
LegendEntry {
label: "Origin",
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(),
legend_entries,
created_date,
created_time,
}
}
}

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 ────────────────────────────────────────────────────
fn exact_match(lower: &str) -> Option<NoteCategory> {

View file

@ -125,17 +125,7 @@ impl TimelineEventView {
Self::add_country_flags(&mut mapped_details);
let tasting_notes = if entity_type == EntityType::Roast {
let notes = 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)
Some(tasting_notes::parse_and_categorize(&tasting_notes))
} else {
None
};