diff --git a/src/presentation/web/views/bags.rs b/src/presentation/web/views/bags.rs index 43a634d..e9bd0e1 100644 --- a/src/presentation/web/views/bags.rs +++ b/src/presentation/web/views/bags.rs @@ -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, } } } diff --git a/src/presentation/web/views/brews.rs b/src/presentation/web/views/brews.rs index 53af38a..7345b31 100644 --- a/src/presentation/web/views/brews.rs +++ b/src/presentation/web/views/brews.rs @@ -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::>() .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, } } } diff --git a/src/presentation/web/views/cafes.rs b/src/presentation/web/views/cafes.rs index 80a53e8..e6f6c13 100644 --- a/src/presentation/web/views/cafes.rs +++ b/src/presentation/web/views/cafes.rs @@ -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 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, diff --git a/src/presentation/web/views/cups.rs b/src/presentation/web/views/cups.rs index da75839..05b9e71 100644 --- a/src/presentation/web/views/cups.rs +++ b/src/presentation/web/views/cups.rs @@ -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, } } } diff --git a/src/presentation/web/views/gear.rs b/src/presentation/web/views/gear.rs index c820ada..4cd650b 100644 --- a/src/presentation/web/views/gear.rs +++ b/src/presentation/web/views/gear.rs @@ -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, } } } diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index 955e7b8..e93d484 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -55,6 +55,13 @@ fn relative_date(dt: DateTime) -> String { crate::domain::formatting::format_relative_time(dt, Utc::now()) } +pub(crate) fn format_datetime(dt: DateTime) -> (String, String) { + ( + dt.format("%Y-%m-%d").to_string(), + dt.format("%H:%M").to_string(), + ) +} + pub struct Paginated { pub items: Vec, 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::>() - }) - .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) { + 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. diff --git a/src/presentation/web/views/roasters.rs b/src/presentation/web/views/roasters.rs index 2b9cc28..87af03d 100644 --- a/src/presentation/web/views/roasters.rs +++ b/src/presentation/web/views/roasters.rs @@ -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 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, diff --git a/src/presentation/web/views/roasts.rs b/src/presentation/web/views/roasts.rs index 8e7c18b..619d6ff 100644 --- a/src/presentation/web/views/roasts.rs +++ b/src/presentation/web/views/roasts.rs @@ -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::>() - }) - .map(|note| tasting_notes::categorize(¬e)) - .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, } } } diff --git a/src/presentation/web/views/tasting_notes.rs b/src/presentation/web/views/tasting_notes.rs index 1ea4140..21b214c 100644 --- a/src/presentation/web/views/tasting_notes.rs +++ b/src/presentation/web/views/tasting_notes.rs @@ -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 { + notes + .iter() + .flat_map(|note| { + note.split([',', '\n']) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .map(|n| categorize(&n)) + .collect() +} + // ── Exact matches ──────────────────────────────────────────────────── fn exact_match(lower: &str) -> Option { diff --git a/src/presentation/web/views/timeline.rs b/src/presentation/web/views/timeline.rs index 08c49e8..c56d4d8 100644 --- a/src/presentation/web/views/timeline.rs +++ b/src/presentation/web/views/timeline.rs @@ -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::>() - }) - .map(|note| tasting_notes::categorize(¬e)) - .collect::>(); - Some(notes) + Some(tasting_notes::parse_and_categorize(&tasting_notes)) } else { None };