From acaadbbcf698b97f0daa10058d76d80037b895db Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 16:58:44 +0000 Subject: [PATCH] refactor(web): split views.rs into entity-specific submodules - Move entity view models into views/{entity}.rs submodules - Keep Paginated and ListNavigator in views/mod.rs as shared infrastructure - Re-export all types from mod.rs to preserve existing import paths --- src/presentation/web/views.rs | 962 ------------------------- src/presentation/web/views/bags.rs | 57 ++ src/presentation/web/views/brews.rs | 113 +++ src/presentation/web/views/cafes.rs | 75 ++ src/presentation/web/views/cups.rs | 44 ++ src/presentation/web/views/gear.rs | 41 ++ src/presentation/web/views/mod.rs | 314 ++++++++ src/presentation/web/views/roasters.rs | 74 ++ src/presentation/web/views/roasts.rs | 99 +++ src/presentation/web/views/timeline.rs | 165 +++++ 10 files changed, 982 insertions(+), 962 deletions(-) delete mode 100644 src/presentation/web/views.rs create mode 100644 src/presentation/web/views/bags.rs create mode 100644 src/presentation/web/views/brews.rs create mode 100644 src/presentation/web/views/cafes.rs create mode 100644 src/presentation/web/views/cups.rs create mode 100644 src/presentation/web/views/gear.rs create mode 100644 src/presentation/web/views/mod.rs create mode 100644 src/presentation/web/views/roasters.rs create mode 100644 src/presentation/web/views/roasts.rs create mode 100644 src/presentation/web/views/timeline.rs diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs deleted file mode 100644 index 3ef6bd5..0000000 --- a/src/presentation/web/views.rs +++ /dev/null @@ -1,962 +0,0 @@ -use crate::domain::bags::BagWithRoast; -use crate::domain::brews::{Brew, BrewWithDetails}; -use crate::domain::cafes::Cafe; -use crate::domain::cups::CupWithDetails; -use crate::domain::gear::Gear; -use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; -use crate::domain::roasters::Roaster; -use crate::domain::roasts::{Roast, RoastWithRoaster}; -use crate::domain::timeline::{TimelineEvent, TimelineEventDetail}; - -pub struct Paginated { - pub items: Vec, - pub page: u32, - pub page_size: u32, - pub total: u64, - pub showing_all: bool, -} - -impl Paginated { - pub fn new(items: Vec, page: u32, page_size: u32, total: u64, showing_all: bool) -> Self { - let page = page.max(1); - let page_size = page_size.max(1); - Self { - items, - page, - page_size, - total, - showing_all, - } - } - - pub fn from_page(page: Page, map_item: MapFn) -> Self - where - MapFn: FnMut(U) -> T, - { - let items = page.items.into_iter().map(map_item).collect(); - - Self::new( - items, - page.page, - page.page_size, - page.total, - page.showing_all, - ) - } - - pub fn total_pages(&self) -> u32 { - if self.total == 0 || self.showing_all { - 1 - } else { - let page_size = u64::from(self.page_size); - self.total.div_ceil(page_size) as u32 - } - } - - pub fn has_previous(&self) -> bool { - !self.showing_all && self.page > 1 - } - - pub fn has_next(&self) -> bool { - !self.showing_all && self.page < self.total_pages() - } - - pub fn previous_page(&self) -> Option { - if self.has_previous() { - Some(self.page - 1) - } else { - None - } - } - - pub fn next_page(&self) -> Option { - if self.has_next() { - Some(self.page + 1) - } else { - None - } - } - - pub fn start_index(&self) -> u64 { - if self.total == 0 { - 0 - } else { - u64::from(self.page - 1) * u64::from(self.page_size) + 1 - } - } - - pub fn end_index(&self) -> u64 { - if self.total == 0 { - 0 - } else { - self.start_index() + self.items.len() as u64 - 1 - } - } - - pub fn is_showing_all(&self) -> bool { - self.showing_all - } - - pub fn is_page_size(&self, value: u32) -> bool { - !self.showing_all && self.page_size == value - } - - pub fn page_size_query_value(&self) -> String { - if self.showing_all { - "all".to_string() - } else { - self.page_size.to_string() - } - } -} - -#[derive(Clone, Debug)] -pub struct ListNavigator { - base_path: &'static str, - fragment_path: &'static str, - request: ListRequest, - search: Option, -} - -impl ListNavigator { - pub fn new( - base_path: &'static str, - fragment_path: &'static str, - request: ListRequest, - search: Option, - ) -> Self { - Self { - base_path, - fragment_path, - request, - search, - } - } - - pub const fn request(&self) -> ListRequest { - self.request - } - - pub fn sort_key(&self) -> &'static str { - self.request.sort_key().query_value() - } - - pub fn sort_direction(&self) -> &'static str { - self.request.sort_direction().as_str() - } - - pub fn page(&self) -> u32 { - self.request.page() - } - - pub fn page_size_value(&self) -> String { - self.request.page_size().to_query_value() - } - - pub fn is_showing_all(&self) -> bool { - self.request.page_size().is_all() - } - - pub fn page_href(&self, page: u32) -> String { - self.build_href(self.base_path, self.request.with_page(page)) - } - - pub fn fragment_page_href(&self, page: u32) -> String { - self.build_href(self.fragment_path, self.request.with_page(page)) - } - - pub fn rows_href(&self, value: &str) -> String { - self.build_href(self.base_path, Self::request_for_rows(self.request, value)) - } - - pub fn fragment_rows_href(&self, value: &str) -> String { - self.build_href( - self.fragment_path, - Self::request_for_rows(self.request, value), - ) - } - - pub fn sort_href(&self, key: &str) -> String { - self.build_href(self.base_path, Self::request_for_sort(self.request, key)) - } - - pub fn fragment_sort_href(&self, key: &str) -> String { - self.build_href( - self.fragment_path, - Self::request_for_sort(self.request, key), - ) - } - - pub fn is_sorted_by(&self, key: &str) -> bool { - K::from_query(key).is_some_and(|candidate| candidate == self.request.sort_key()) - } - - pub fn next_sort_dir(&self, key: &str) -> &'static str { - let sort_key = K::from_query(key).unwrap_or_else(K::default); - let direction = if sort_key == self.request.sort_key() { - self.request.sort_direction().opposite() - } else { - sort_key.default_direction() - }; - direction.as_str() - } - - pub fn query(&self) -> String { - self.build_query_string(self.request) - } - - pub fn query_for_page(&self, page: u32) -> String { - self.build_query_string(self.request.with_page(page)) - } - - pub fn query_for_rows(&self, value: &str) -> String { - self.build_query_string(Self::request_for_rows(self.request, value)) - } - - pub fn query_for_sort(&self, key: &str) -> String { - self.build_query_string(Self::request_for_sort(self.request, key)) - } - - pub fn search_value(&self) -> &str { - self.search.as_deref().unwrap_or("") - } - - pub fn has_search(&self) -> bool { - self.search.is_some() - } - - /// Returns the base path (e.g., "/roasters") without query or fragment. - pub fn path(&self) -> &str { - self.base_path - } - - /// Returns query params for search actions (page reset to 1, preserves `sort/page_size`). - /// Does NOT include the `q` parameter — the template appends it dynamically from JS. - pub fn search_query_base(&self) -> String { - format!( - "page=1&page_size={}&sort={}&dir={}", - self.request.page_size().to_query_value(), - self.request.sort_key().query_value(), - self.request.sort_direction().as_str() - ) - } - - fn build_href(&self, path: &str, request: ListRequest) -> String { - if let Some((base, fragment)) = path.split_once('#') { - format!("{}?{}#{}", base, self.build_query_string(request), fragment) - } else { - format!("{}?{}", path, self.build_query_string(request)) - } - } - - fn request_for_rows(request: ListRequest, value: &str) -> ListRequest { - let page_size = page_size_from_text(value); - request.with_page(1).with_page_size(page_size) - } - - fn request_for_sort(request: ListRequest, key: &str) -> ListRequest { - let sort_key = K::from_query(key).unwrap_or_else(K::default); - request.with_page(1).with_sort(sort_key) - } - - fn build_query_string(&self, request: ListRequest) -> String { - let mut qs = format!( - "page={}&page_size={}&sort={}&dir={}", - request.page(), - request.page_size().to_query_value(), - request.sort_key().query_value(), - request.sort_direction().as_str() - ); - if let Some(ref q) = self.search { - qs.push_str("&q="); - qs.push_str(&encode_uri_component(q)); - } - qs - } -} - -fn encode_uri_component(s: &str) -> String { - use std::fmt::Write; - let mut result = String::with_capacity(s.len() * 3); - for byte in s.as_bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - result.push(*byte as char); - } - _ => { - let _ = write!(result, "%{byte:02X}"); - } - } - } - result -} - -fn page_size_from_text(value: &str) -> PageSize { - if value.eq_ignore_ascii_case("all") { - PageSize::All - } else if let Ok(parsed) = value.parse::() { - PageSize::limited(parsed) - } else { - PageSize::limited(DEFAULT_PAGE_SIZE) - } -} - -pub struct RoasterOptionView { - pub id: String, - pub name: String, -} - -impl From for RoasterOptionView { - fn from(roaster: Roaster) -> Self { - Self { - id: roaster.id.to_string(), - name: roaster.name, - } - } -} - -impl From<&Roaster> for RoasterOptionView { - fn from(roaster: &Roaster) -> Self { - Self { - id: roaster.id.to_string(), - name: roaster.name.clone(), - } - } -} - -pub struct RoasterView { - pub id: String, - pub detail_path: String, - pub name: String, - pub country: String, - pub city: String, - pub has_homepage: bool, - pub homepage_url: String, - pub homepage_label: String, - pub notes: String, - pub created_at: String, - pub created_at_sort_key: i64, -} - -impl From for RoasterView { - fn from(roaster: Roaster) -> Self { - let Roaster { - id, - slug, - name, - country, - city, - homepage, - notes, - created_at, - } = roaster; - - let homepage = homepage.unwrap_or_default(); - let has_homepage = !homepage.is_empty(); - let detail_path = format!("/roasters/{slug}"); - - let created_at_sort_key = created_at.timestamp(); - let created_at_label = created_at.format("%Y-%m-%d").to_string(); - - Self { - detail_path, - id: id.to_string(), - name, - country, - city: city.unwrap_or_else(|| "—".to_string()), - has_homepage, - homepage_url: homepage.clone(), - homepage_label: homepage, - notes: notes.unwrap_or_else(|| "This roaster has no notes yet.".to_string()), - created_at: created_at_label, - created_at_sort_key, - } - } -} - -pub struct RoastView { - pub id: String, - pub full_id: String, - pub detail_path: String, - pub name: String, - pub roaster_label: String, - pub origin: String, - pub region: String, - pub producer: String, - pub process: String, - pub created_at: String, - pub created_at_sort_key: i64, - pub tasting_notes: Vec, -} - -impl RoastView { - pub fn from_domain(roast: Roast, roaster_name: &str, roaster_slug: &str) -> Self { - Self::from_parts(roast, roaster_name, roaster_slug) - } - - pub fn from_list_item(item: RoastWithRoaster) -> Self { - let RoastWithRoaster { - roast, - roaster_name, - roaster_slug, - } = item; - Self::from_parts(roast, &roaster_name, &roaster_slug) - } - - fn from_parts(roast: Roast, roaster_name: &str, roaster_slug: &str) -> Self { - let Roast { - id: roast_id, - roaster_id: _, - name, - slug, - origin, - region, - producer, - tasting_notes, - process, - created_at, - } = roast; - - let full_id = roast_id.to_string(); - let id: String = full_id.chars().take(6).collect(); - let roaster_label = if roaster_name.trim().is_empty() { - "Unknown roaster".to_string() - } else { - roaster_name.to_string() - }; - let origin = origin.unwrap_or_else(|| "—".to_string()); - let region = region.unwrap_or_else(|| "—".to_string()); - 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::>() - }) - .collect(); - let created_at = created_at.format("%Y-%m-%d").to_string(); - let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}"); - - Self { - id, - full_id, - detail_path, - name, - roaster_label, - origin, - region, - producer, - process, - created_at, - created_at_sort_key, - tasting_notes, - } - } -} - -#[derive(Clone)] -pub struct TimelineEventDetailView { - pub label: String, - pub value: String, - pub link: Option, -} - -/// Raw brew data for repeating a brew from the timeline. -#[derive(Clone)] -pub struct TimelineBrewDataView { - pub bag_id: i64, - pub grinder_id: i64, - pub brewer_id: i64, - pub filter_paper_id: Option, - pub coffee_weight: f64, - pub grind_setting: f64, - pub water_volume: i32, - pub water_temp: f64, -} - -#[derive(Clone)] -pub struct TimelineEventView { - pub id: String, - pub kind_label: &'static str, - pub date_label: String, - pub time_label: Option, - pub iso_timestamp: String, - pub title: String, - pub link: String, - pub external_link: Option, - pub details: Vec, - pub tasting_notes: Option>, - pub brew_data: Option, -} - -pub struct TimelineMonthView { - pub anchor: String, - pub heading: String, - pub events: Vec, -} - -impl TimelineEventView { - pub fn from_domain(event: TimelineEvent) -> Self { - let TimelineEvent { - id, - entity_type, - entity_id, - action, - occurred_at, - title, - details, - tasting_notes, - slug, - roaster_slug, - brew_data, - } = event; - - let kind_label = match (entity_type.as_str(), action.as_str()) { - ("roaster", "added") => "Roaster Added", - ("roast", "added") => "Roast Added", - ("bag", "added") => "Bag Added", - ("bag", "finished") => "Bag Finished", - ("gear", "added") => "Gear Added", - ("brew", "brewed") => "Brewed", - ("cafe", "added") => "Cafe Added", - ("cup", "added") => "Cup", - _ => "Event", - }; - - let link = match (entity_type.as_str(), slug, roaster_slug) { - ("roaster", Some(slug), _) => format!("/roasters/{slug}"), - // Roasts, bags, brews, and cups link to the roast page when we have slug info - ("roast" | "bag" | "brew" | "cup", Some(slug), Some(roaster_slug)) => { - format!("/roasters/{roaster_slug}/roasts/{slug}") - } - ("cafe", Some(slug), _) => format!("/cafes/{slug}"), - ("cup", _, _) => "/cups".to_string(), - ("gear", _, _) => "/gear".to_string(), - ("brew", _, _) => "/brews".to_string(), - ("roaster", None, _) => format!("/roasters/{entity_id}"), - ("roast", None, _) => format!("/roasts/{entity_id}"), - _ => String::from("#"), - }; - - let (mapped_details, external_link) = Self::map_details(details); - - let tasting_notes = if entity_type == "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::>() - }) - .collect::>(); - Some(notes) - } else { - None - }; - - let brew_data_view = brew_data.map(|bd| TimelineBrewDataView { - bag_id: bd.bag_id, - grinder_id: bd.grinder_id, - brewer_id: bd.brewer_id, - filter_paper_id: bd.filter_paper_id, - coffee_weight: bd.coffee_weight, - grind_setting: bd.grind_setting, - water_volume: bd.water_volume, - water_temp: bd.water_temp, - }); - - Self { - id: id.to_string(), - kind_label, - date_label: occurred_at.format("%B %d, %Y").to_string(), - time_label: Some(occurred_at.format("%H:%M UTC").to_string()), - iso_timestamp: occurred_at.to_rfc3339(), - title, - link, - external_link, - details: mapped_details, - tasting_notes, - brew_data: brew_data_view, - } - } - - fn map_details( - details: Vec, - ) -> (Vec, Option) { - let mut mapped = Vec::new(); - let mut external_link = None; - for detail in details { - if detail.label.eq_ignore_ascii_case("homepage") - || detail.label.eq_ignore_ascii_case("website") - { - let trimmed = detail.value.trim(); - if !trimmed.is_empty() && trimmed != "—" { - external_link = Some(trimmed.to_string()); - } - } else if detail.label.eq_ignore_ascii_case("position") { - let trimmed = detail.value.trim(); - if !trimmed.is_empty() { - let display = trimmed - .strip_prefix("https://www.google.com/maps?q=") - .unwrap_or(trimmed); - mapped.push(TimelineEventDetailView { - label: detail.label, - value: display.to_string(), - link: Some(trimmed.to_string()), - }); - } - } else { - mapped.push(TimelineEventDetailView { - label: detail.label, - value: detail.value, - link: None, - }); - } - } - (mapped, external_link) - } -} - -#[derive(Debug, Clone)] -pub struct BagView { - pub id: String, - pub roast_id: String, - pub roast_date: Option, - pub amount: String, - pub remaining: String, - pub closed: bool, - pub finished_at: String, - pub created_at: String, - pub roast_name: String, - pub roaster_name: String, - pub roast_slug: String, - pub roaster_slug: String, -} - -impl BagView { - pub fn from_domain(bag: BagWithRoast) -> Self { - Self { - id: bag.bag.id.to_string(), - roast_id: bag.bag.roast_id.to_string(), - roast_date: bag.bag.roast_date.map(|d| d.to_string()), - amount: format!("{:.1}", bag.bag.amount), - remaining: format!("{:.1}", bag.bag.remaining), - closed: bag.bag.closed, - finished_at: bag - .bag - .finished_at - .map_or_else(|| "—".to_string(), |d| d.to_string()), - created_at: bag.bag.created_at.format("%Y-%m-%d").to_string(), - roast_name: bag.roast_name, - roaster_name: bag.roaster_name, - roast_slug: bag.roast_slug, - roaster_slug: bag.roaster_slug, - } - } -} - -#[derive(Clone)] -pub struct GearView { - pub id: String, - pub category: String, - pub category_label: String, - pub make: String, - pub model: String, - pub full_name: String, - pub created_at: String, -} - -impl GearView { - pub fn from_domain(gear: crate::domain::gear::Gear) -> Self { - Self { - id: gear.id.to_string(), - category: gear.category.as_str().to_string(), - category_label: gear.category.display_label().to_string(), - make: gear.make.clone(), - model: gear.model.clone(), - full_name: format!("{} {}", gear.make, gear.model), - created_at: gear.created_at.format("%Y-%m-%d").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 grind_setting: String, - pub brewer_id: i64, - pub brewer_name: String, - pub filter_paper_id: Option, - pub filter_paper_name: Option, - pub water_volume: String, - pub water_temp: String, - pub ratio: String, - pub created_at: 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, -} - -impl BrewView { - pub fn from_domain(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() - }; - - 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!("{:.1}g", brew.brew.coffee_weight), - grinder_id: brew.brew.grinder_id.into_inner(), - 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, - created_at: brew.brew.created_at.format("%Y-%m-%d %H:%M").to_string(), - 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, - } - } -} - -#[derive(Clone)] -pub struct BagOptionView { - pub id: String, - pub label: String, -} - -impl From for BagOptionView { - fn from(bag: BagWithRoast) -> Self { - Self { - id: bag.bag.id.to_string(), - label: format!( - "{} - {} ({:.0}g remaining)", - bag.roaster_name, bag.roast_name, bag.bag.remaining - ), - } - } -} - -#[derive(Clone)] -pub struct GearOptionView { - pub id: String, - pub label: String, -} - -impl From for GearOptionView { - fn from(gear: Gear) -> Self { - Self { - id: gear.id.to_string(), - label: format!("{} {}", gear.make, gear.model), - } - } -} - -pub struct BrewDefaultsView { - pub bag_id: String, - pub grinder_id: String, - pub brewer_id: String, - pub filter_paper_id: String, - pub coffee_weight: f64, - pub grind_setting: f64, - pub water_volume: i32, - pub water_temp: f64, -} - -impl Default for BrewDefaultsView { - fn default() -> Self { - Self { - bag_id: String::new(), - grinder_id: String::new(), - brewer_id: String::new(), - filter_paper_id: String::new(), - coffee_weight: 15.0, - grind_setting: 6.0, - water_volume: 250, - water_temp: 91.0, - } - } -} - -impl From for BrewDefaultsView { - fn from(brew: Brew) -> Self { - Self { - bag_id: brew.bag_id.to_string(), - grinder_id: brew.grinder_id.to_string(), - brewer_id: brew.brewer_id.to_string(), - filter_paper_id: brew - .filter_paper_id - .map(|id| id.to_string()) - .unwrap_or_default(), - coffee_weight: brew.coffee_weight, - grind_setting: brew.grind_setting, - water_volume: brew.water_volume, - water_temp: brew.water_temp, - } - } -} - -pub struct CafeView { - pub id: String, - pub detail_path: String, - pub name: String, - pub city: String, - pub country: String, - pub latitude: f64, - pub longitude: f64, - pub map_url: String, - pub has_website: bool, - pub website_url: String, - pub website_label: String, - pub notes: String, - pub created_at: String, - pub created_at_sort_key: i64, -} - -impl From for CafeView { - fn from(cafe: Cafe) -> Self { - let Cafe { - id, - slug, - name, - city, - country, - latitude, - longitude, - website, - notes, - created_at, - updated_at: _, - } = cafe; - - let website = website.unwrap_or_default(); - let has_website = !website.is_empty(); - let detail_path = format!("/cafes/{slug}"); - let map_url = format!("https://www.google.com/maps?q={latitude},{longitude}"); - - let created_at_sort_key = created_at.timestamp(); - let created_at_label = created_at.format("%Y-%m-%d").to_string(); - - Self { - detail_path, - id: id.to_string(), - name, - city, - country, - latitude, - longitude, - map_url, - has_website, - website_url: website.clone(), - website_label: website, - notes: notes.unwrap_or_else(|| "This cafe has no notes yet.".to_string()), - created_at: created_at_label, - created_at_sort_key, - } - } -} - -pub struct CafeOptionView { - pub id: String, - pub label: String, -} - -impl From for CafeOptionView { - fn from(cafe: Cafe) -> Self { - Self { - id: cafe.id.to_string(), - label: format!("{} ({})", cafe.name, cafe.city), - } - } -} - -pub struct RoastOptionView { - pub id: String, - pub label: String, -} - -impl From for RoastOptionView { - fn from(roast: RoastWithRoaster) -> Self { - Self { - id: roast.roast.id.to_string(), - label: format!("{} - {}", roast.roaster_name, roast.roast.name), - } - } -} - -#[derive(Clone)] -pub struct CupView { - pub id: String, - pub roast_name: String, - pub roaster_name: String, - pub roast_slug: String, - pub roaster_slug: String, - pub cafe_name: String, - pub cafe_slug: String, - pub notes: String, - pub has_notes: bool, - pub rating: String, - pub has_rating: bool, - pub created_at: String, -} - -impl CupView { - pub fn from_domain(cup: CupWithDetails) -> Self { - let notes = cup.cup.notes.clone().unwrap_or_default(); - let has_notes = !notes.is_empty(); - let rating = cup - .cup - .rating - .map_or_else(|| "—".to_string(), |r| format!("{r}/5")); - let has_rating = cup.cup.rating.is_some(); - - Self { - id: cup.cup.id.to_string(), - roast_name: cup.roast_name, - roaster_name: cup.roaster_name, - roast_slug: cup.roast_slug, - roaster_slug: cup.roaster_slug, - cafe_name: cup.cafe_name, - cafe_slug: cup.cafe_slug, - notes, - has_notes, - rating, - has_rating, - created_at: cup.cup.created_at.format("%Y-%m-%d %H:%M").to_string(), - } - } -} diff --git a/src/presentation/web/views/bags.rs b/src/presentation/web/views/bags.rs new file mode 100644 index 0000000..bdafcb1 --- /dev/null +++ b/src/presentation/web/views/bags.rs @@ -0,0 +1,57 @@ +use crate::domain::bags::BagWithRoast; + +#[derive(Debug, Clone)] +pub struct BagView { + pub id: String, + pub roast_id: String, + pub roast_date: Option, + pub amount: String, + pub remaining: String, + pub closed: bool, + pub finished_at: String, + pub created_at: String, + pub roast_name: String, + pub roaster_name: String, + pub roast_slug: String, + pub roaster_slug: String, +} + +impl BagView { + pub fn from_domain(bag: BagWithRoast) -> Self { + Self { + id: bag.bag.id.to_string(), + roast_id: bag.bag.roast_id.to_string(), + roast_date: bag.bag.roast_date.map(|d| d.to_string()), + amount: format!("{:.1}", bag.bag.amount), + remaining: format!("{:.1}", bag.bag.remaining), + closed: bag.bag.closed, + finished_at: bag + .bag + .finished_at + .map_or_else(|| "—".to_string(), |d| d.to_string()), + created_at: bag.bag.created_at.format("%Y-%m-%d").to_string(), + roast_name: bag.roast_name, + roaster_name: bag.roaster_name, + roast_slug: bag.roast_slug, + roaster_slug: bag.roaster_slug, + } + } +} + +#[derive(Clone)] +pub struct BagOptionView { + pub id: String, + pub label: String, +} + +impl From for BagOptionView { + fn from(bag: BagWithRoast) -> Self { + Self { + id: bag.bag.id.to_string(), + label: format!( + "{} - {} ({:.0}g remaining)", + bag.roaster_name, bag.roast_name, bag.bag.remaining + ), + } + } +} diff --git a/src/presentation/web/views/brews.rs b/src/presentation/web/views/brews.rs new file mode 100644 index 0000000..264d0de --- /dev/null +++ b/src/presentation/web/views/brews.rs @@ -0,0 +1,113 @@ +use crate::domain::brews::{Brew, BrewWithDetails}; + +#[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 grind_setting: String, + pub brewer_id: i64, + pub brewer_name: String, + pub filter_paper_id: Option, + pub filter_paper_name: Option, + pub water_volume: String, + pub water_temp: String, + pub ratio: String, + pub created_at: 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, +} + +impl BrewView { + pub fn from_domain(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() + }; + + 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!("{:.1}g", brew.brew.coffee_weight), + grinder_id: brew.brew.grinder_id.into_inner(), + 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, + created_at: brew.brew.created_at.format("%Y-%m-%d %H:%M").to_string(), + 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, + } + } +} + +pub struct BrewDefaultsView { + pub bag_id: String, + pub grinder_id: String, + pub brewer_id: String, + pub filter_paper_id: String, + pub coffee_weight: f64, + pub grind_setting: f64, + pub water_volume: i32, + pub water_temp: f64, +} + +impl Default for BrewDefaultsView { + fn default() -> Self { + Self { + bag_id: String::new(), + grinder_id: String::new(), + brewer_id: String::new(), + filter_paper_id: String::new(), + coffee_weight: 15.0, + grind_setting: 6.0, + water_volume: 250, + water_temp: 91.0, + } + } +} + +impl From for BrewDefaultsView { + fn from(brew: Brew) -> Self { + Self { + bag_id: brew.bag_id.to_string(), + grinder_id: brew.grinder_id.to_string(), + brewer_id: brew.brewer_id.to_string(), + filter_paper_id: brew + .filter_paper_id + .map(|id| id.to_string()) + .unwrap_or_default(), + coffee_weight: brew.coffee_weight, + grind_setting: brew.grind_setting, + water_volume: brew.water_volume, + water_temp: brew.water_temp, + } + } +} diff --git a/src/presentation/web/views/cafes.rs b/src/presentation/web/views/cafes.rs new file mode 100644 index 0000000..49413e0 --- /dev/null +++ b/src/presentation/web/views/cafes.rs @@ -0,0 +1,75 @@ +use crate::domain::cafes::Cafe; + +pub struct CafeView { + pub id: String, + pub detail_path: String, + pub name: String, + pub city: String, + pub country: String, + pub latitude: f64, + pub longitude: f64, + pub map_url: String, + pub has_website: bool, + pub website_url: String, + pub website_label: String, + pub notes: String, + pub created_at: String, + pub created_at_sort_key: i64, +} + +impl From for CafeView { + fn from(cafe: Cafe) -> Self { + let Cafe { + id, + slug, + name, + city, + country, + latitude, + longitude, + website, + notes, + created_at, + updated_at: _, + } = cafe; + + let website = website.unwrap_or_default(); + let has_website = !website.is_empty(); + let detail_path = format!("/cafes/{slug}"); + let map_url = format!("https://www.google.com/maps?q={latitude},{longitude}"); + + let created_at_sort_key = created_at.timestamp(); + let created_at_label = created_at.format("%Y-%m-%d").to_string(); + + Self { + detail_path, + id: id.to_string(), + name, + city, + country, + latitude, + longitude, + map_url, + has_website, + website_url: website.clone(), + website_label: website, + notes: notes.unwrap_or_else(|| "This cafe has no notes yet.".to_string()), + created_at: created_at_label, + created_at_sort_key, + } + } +} + +pub struct CafeOptionView { + pub id: String, + pub label: String, +} + +impl From for CafeOptionView { + fn from(cafe: Cafe) -> Self { + Self { + id: cafe.id.to_string(), + label: format!("{} ({})", cafe.name, cafe.city), + } + } +} diff --git a/src/presentation/web/views/cups.rs b/src/presentation/web/views/cups.rs new file mode 100644 index 0000000..63a5674 --- /dev/null +++ b/src/presentation/web/views/cups.rs @@ -0,0 +1,44 @@ +use crate::domain::cups::CupWithDetails; + +#[derive(Clone)] +pub struct CupView { + pub id: String, + pub roast_name: String, + pub roaster_name: String, + pub roast_slug: String, + pub roaster_slug: String, + pub cafe_name: String, + pub cafe_slug: String, + pub notes: String, + pub has_notes: bool, + pub rating: String, + pub has_rating: bool, + pub created_at: String, +} + +impl CupView { + pub fn from_domain(cup: CupWithDetails) -> Self { + let notes = cup.cup.notes.clone().unwrap_or_default(); + let has_notes = !notes.is_empty(); + let rating = cup + .cup + .rating + .map_or_else(|| "—".to_string(), |r| format!("{r}/5")); + let has_rating = cup.cup.rating.is_some(); + + Self { + id: cup.cup.id.to_string(), + roast_name: cup.roast_name, + roaster_name: cup.roaster_name, + roast_slug: cup.roast_slug, + roaster_slug: cup.roaster_slug, + cafe_name: cup.cafe_name, + cafe_slug: cup.cafe_slug, + notes, + has_notes, + rating, + has_rating, + created_at: cup.cup.created_at.format("%Y-%m-%d %H:%M").to_string(), + } + } +} diff --git a/src/presentation/web/views/gear.rs b/src/presentation/web/views/gear.rs new file mode 100644 index 0000000..7e185c7 --- /dev/null +++ b/src/presentation/web/views/gear.rs @@ -0,0 +1,41 @@ +use crate::domain::gear::Gear; + +#[derive(Clone)] +pub struct GearView { + pub id: String, + pub category: String, + pub category_label: String, + pub make: String, + pub model: String, + pub full_name: String, + pub created_at: String, +} + +impl GearView { + pub fn from_domain(gear: Gear) -> Self { + Self { + id: gear.id.to_string(), + category: gear.category.as_str().to_string(), + category_label: gear.category.display_label().to_string(), + make: gear.make.clone(), + model: gear.model.clone(), + full_name: format!("{} {}", gear.make, gear.model), + created_at: gear.created_at.format("%Y-%m-%d").to_string(), + } + } +} + +#[derive(Clone)] +pub struct GearOptionView { + pub id: String, + pub label: String, +} + +impl From for GearOptionView { + fn from(gear: Gear) -> Self { + Self { + id: gear.id.to_string(), + label: format!("{} {}", gear.make, gear.model), + } + } +} diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs new file mode 100644 index 0000000..8f9504f --- /dev/null +++ b/src/presentation/web/views/mod.rs @@ -0,0 +1,314 @@ +mod bags; +mod brews; +mod cafes; +mod cups; +mod gear; +mod roasters; +mod roasts; +mod timeline; + +pub use bags::{BagOptionView, BagView}; +pub use brews::{BrewDefaultsView, BrewView}; +pub use cafes::{CafeOptionView, CafeView}; +pub use cups::CupView; +pub use gear::{GearOptionView, GearView}; +pub use roasters::{RoasterOptionView, RoasterView}; +pub use roasts::{RoastOptionView, RoastView}; +pub use timeline::{ + TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView, +}; + +use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; + +pub struct Paginated { + pub items: Vec, + pub page: u32, + pub page_size: u32, + pub total: u64, + pub showing_all: bool, +} + +impl Paginated { + pub fn new(items: Vec, page: u32, page_size: u32, total: u64, showing_all: bool) -> Self { + let page = page.max(1); + let page_size = page_size.max(1); + Self { + items, + page, + page_size, + total, + showing_all, + } + } + + pub fn from_page(page: Page, map_item: MapFn) -> Self + where + MapFn: FnMut(U) -> T, + { + let items = page.items.into_iter().map(map_item).collect(); + + Self::new( + items, + page.page, + page.page_size, + page.total, + page.showing_all, + ) + } + + pub fn total_pages(&self) -> u32 { + if self.total == 0 || self.showing_all { + 1 + } else { + let page_size = u64::from(self.page_size); + self.total.div_ceil(page_size) as u32 + } + } + + pub fn has_previous(&self) -> bool { + !self.showing_all && self.page > 1 + } + + pub fn has_next(&self) -> bool { + !self.showing_all && self.page < self.total_pages() + } + + pub fn previous_page(&self) -> Option { + if self.has_previous() { + Some(self.page - 1) + } else { + None + } + } + + pub fn next_page(&self) -> Option { + if self.has_next() { + Some(self.page + 1) + } else { + None + } + } + + pub fn start_index(&self) -> u64 { + if self.total == 0 { + 0 + } else { + u64::from(self.page - 1) * u64::from(self.page_size) + 1 + } + } + + pub fn end_index(&self) -> u64 { + if self.total == 0 { + 0 + } else { + self.start_index() + self.items.len() as u64 - 1 + } + } + + pub fn is_showing_all(&self) -> bool { + self.showing_all + } + + pub fn is_page_size(&self, value: u32) -> bool { + !self.showing_all && self.page_size == value + } + + pub fn page_size_query_value(&self) -> String { + if self.showing_all { + "all".to_string() + } else { + self.page_size.to_string() + } + } +} + +#[derive(Clone, Debug)] +pub struct ListNavigator { + base_path: &'static str, + fragment_path: &'static str, + request: ListRequest, + search: Option, +} + +impl ListNavigator { + pub fn new( + base_path: &'static str, + fragment_path: &'static str, + request: ListRequest, + search: Option, + ) -> Self { + Self { + base_path, + fragment_path, + request, + search, + } + } + + pub const fn request(&self) -> ListRequest { + self.request + } + + pub fn sort_key(&self) -> &'static str { + self.request.sort_key().query_value() + } + + pub fn sort_direction(&self) -> &'static str { + self.request.sort_direction().as_str() + } + + pub fn page(&self) -> u32 { + self.request.page() + } + + pub fn page_size_value(&self) -> String { + self.request.page_size().to_query_value() + } + + pub fn is_showing_all(&self) -> bool { + self.request.page_size().is_all() + } + + pub fn page_href(&self, page: u32) -> String { + self.build_href(self.base_path, self.request.with_page(page)) + } + + pub fn fragment_page_href(&self, page: u32) -> String { + self.build_href(self.fragment_path, self.request.with_page(page)) + } + + pub fn rows_href(&self, value: &str) -> String { + self.build_href(self.base_path, Self::request_for_rows(self.request, value)) + } + + pub fn fragment_rows_href(&self, value: &str) -> String { + self.build_href( + self.fragment_path, + Self::request_for_rows(self.request, value), + ) + } + + pub fn sort_href(&self, key: &str) -> String { + self.build_href(self.base_path, Self::request_for_sort(self.request, key)) + } + + pub fn fragment_sort_href(&self, key: &str) -> String { + self.build_href( + self.fragment_path, + Self::request_for_sort(self.request, key), + ) + } + + pub fn is_sorted_by(&self, key: &str) -> bool { + K::from_query(key).is_some_and(|candidate| candidate == self.request.sort_key()) + } + + pub fn next_sort_dir(&self, key: &str) -> &'static str { + let sort_key = K::from_query(key).unwrap_or_else(K::default); + let direction = if sort_key == self.request.sort_key() { + self.request.sort_direction().opposite() + } else { + sort_key.default_direction() + }; + direction.as_str() + } + + pub fn query(&self) -> String { + self.build_query_string(self.request) + } + + pub fn query_for_page(&self, page: u32) -> String { + self.build_query_string(self.request.with_page(page)) + } + + pub fn query_for_rows(&self, value: &str) -> String { + self.build_query_string(Self::request_for_rows(self.request, value)) + } + + pub fn query_for_sort(&self, key: &str) -> String { + self.build_query_string(Self::request_for_sort(self.request, key)) + } + + pub fn search_value(&self) -> &str { + self.search.as_deref().unwrap_or("") + } + + pub fn has_search(&self) -> bool { + self.search.is_some() + } + + /// Returns the base path (e.g., "/roasters") without query or fragment. + pub fn path(&self) -> &str { + self.base_path + } + + /// Returns query params for search actions (page reset to 1, preserves `sort/page_size`). + /// Does NOT include the `q` parameter — the template appends it dynamically from JS. + pub fn search_query_base(&self) -> String { + format!( + "page=1&page_size={}&sort={}&dir={}", + self.request.page_size().to_query_value(), + self.request.sort_key().query_value(), + self.request.sort_direction().as_str() + ) + } + + fn build_href(&self, path: &str, request: ListRequest) -> String { + if let Some((base, fragment)) = path.split_once('#') { + format!("{}?{}#{}", base, self.build_query_string(request), fragment) + } else { + format!("{}?{}", path, self.build_query_string(request)) + } + } + + fn request_for_rows(request: ListRequest, value: &str) -> ListRequest { + let page_size = page_size_from_text(value); + request.with_page(1).with_page_size(page_size) + } + + fn request_for_sort(request: ListRequest, key: &str) -> ListRequest { + let sort_key = K::from_query(key).unwrap_or_else(K::default); + request.with_page(1).with_sort(sort_key) + } + + fn build_query_string(&self, request: ListRequest) -> String { + let mut qs = format!( + "page={}&page_size={}&sort={}&dir={}", + request.page(), + request.page_size().to_query_value(), + request.sort_key().query_value(), + request.sort_direction().as_str() + ); + if let Some(ref q) = self.search { + qs.push_str("&q="); + qs.push_str(&encode_uri_component(q)); + } + qs + } +} + +fn encode_uri_component(s: &str) -> String { + use std::fmt::Write; + let mut result = String::with_capacity(s.len() * 3); + for byte in s.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + result.push(*byte as char); + } + _ => { + let _ = write!(result, "%{byte:02X}"); + } + } + } + result +} + +fn page_size_from_text(value: &str) -> PageSize { + if value.eq_ignore_ascii_case("all") { + PageSize::All + } else if let Ok(parsed) = value.parse::() { + PageSize::limited(parsed) + } else { + PageSize::limited(DEFAULT_PAGE_SIZE) + } +} diff --git a/src/presentation/web/views/roasters.rs b/src/presentation/web/views/roasters.rs new file mode 100644 index 0000000..7348e02 --- /dev/null +++ b/src/presentation/web/views/roasters.rs @@ -0,0 +1,74 @@ +use crate::domain::roasters::Roaster; + +pub struct RoasterOptionView { + pub id: String, + pub name: String, +} + +impl From for RoasterOptionView { + fn from(roaster: Roaster) -> Self { + Self { + id: roaster.id.to_string(), + name: roaster.name, + } + } +} + +impl From<&Roaster> for RoasterOptionView { + fn from(roaster: &Roaster) -> Self { + Self { + id: roaster.id.to_string(), + name: roaster.name.clone(), + } + } +} + +pub struct RoasterView { + pub id: String, + pub detail_path: String, + pub name: String, + pub country: String, + pub city: String, + pub has_homepage: bool, + pub homepage_url: String, + pub homepage_label: String, + pub notes: String, + pub created_at: String, + pub created_at_sort_key: i64, +} + +impl From for RoasterView { + fn from(roaster: Roaster) -> Self { + let Roaster { + id, + slug, + name, + country, + city, + homepage, + notes, + created_at, + } = roaster; + + let homepage = homepage.unwrap_or_default(); + let has_homepage = !homepage.is_empty(); + let detail_path = format!("/roasters/{slug}"); + + let created_at_sort_key = created_at.timestamp(); + let created_at_label = created_at.format("%Y-%m-%d").to_string(); + + Self { + detail_path, + id: id.to_string(), + name, + country, + city: city.unwrap_or_else(|| "—".to_string()), + has_homepage, + homepage_url: homepage.clone(), + homepage_label: homepage, + notes: notes.unwrap_or_else(|| "This roaster has no notes yet.".to_string()), + created_at: created_at_label, + created_at_sort_key, + } + } +} diff --git a/src/presentation/web/views/roasts.rs b/src/presentation/web/views/roasts.rs new file mode 100644 index 0000000..d07701c --- /dev/null +++ b/src/presentation/web/views/roasts.rs @@ -0,0 +1,99 @@ +use crate::domain::roasts::{Roast, RoastWithRoaster}; + +pub struct RoastView { + pub id: String, + pub full_id: String, + pub detail_path: String, + pub name: String, + pub roaster_label: String, + pub origin: String, + pub region: String, + pub producer: String, + pub process: String, + pub created_at: String, + pub created_at_sort_key: i64, + pub tasting_notes: Vec, +} + +impl RoastView { + pub fn from_domain(roast: Roast, roaster_name: &str, roaster_slug: &str) -> Self { + Self::from_parts(roast, roaster_name, roaster_slug) + } + + pub fn from_list_item(item: RoastWithRoaster) -> Self { + let RoastWithRoaster { + roast, + roaster_name, + roaster_slug, + } = item; + Self::from_parts(roast, &roaster_name, &roaster_slug) + } + + fn from_parts(roast: Roast, roaster_name: &str, roaster_slug: &str) -> Self { + let Roast { + id: roast_id, + roaster_id: _, + name, + slug, + origin, + region, + producer, + tasting_notes, + process, + created_at, + } = roast; + + let full_id = roast_id.to_string(); + let id: String = full_id.chars().take(6).collect(); + let roaster_label = if roaster_name.trim().is_empty() { + "Unknown roaster".to_string() + } else { + roaster_name.to_string() + }; + let origin = origin.unwrap_or_else(|| "—".to_string()); + let region = region.unwrap_or_else(|| "—".to_string()); + 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::>() + }) + .collect(); + let created_at = created_at.format("%Y-%m-%d").to_string(); + let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}"); + + Self { + id, + full_id, + detail_path, + name, + roaster_label, + origin, + region, + producer, + process, + created_at, + created_at_sort_key, + tasting_notes, + } + } +} + +pub struct RoastOptionView { + pub id: String, + pub label: String, +} + +impl From for RoastOptionView { + fn from(roast: RoastWithRoaster) -> Self { + Self { + id: roast.roast.id.to_string(), + label: format!("{} - {}", roast.roaster_name, roast.roast.name), + } + } +} diff --git a/src/presentation/web/views/timeline.rs b/src/presentation/web/views/timeline.rs new file mode 100644 index 0000000..82f9820 --- /dev/null +++ b/src/presentation/web/views/timeline.rs @@ -0,0 +1,165 @@ +use crate::domain::timeline::{TimelineEvent, TimelineEventDetail}; + +#[derive(Clone)] +pub struct TimelineEventDetailView { + pub label: String, + pub value: String, + pub link: Option, +} + +/// Raw brew data for repeating a brew from the timeline. +#[derive(Clone)] +pub struct TimelineBrewDataView { + pub bag_id: i64, + pub grinder_id: i64, + pub brewer_id: i64, + pub filter_paper_id: Option, + pub coffee_weight: f64, + pub grind_setting: f64, + pub water_volume: i32, + pub water_temp: f64, +} + +#[derive(Clone)] +pub struct TimelineEventView { + pub id: String, + pub kind_label: &'static str, + pub date_label: String, + pub time_label: Option, + pub iso_timestamp: String, + pub title: String, + pub link: String, + pub external_link: Option, + pub details: Vec, + pub tasting_notes: Option>, + pub brew_data: Option, +} + +pub struct TimelineMonthView { + pub anchor: String, + pub heading: String, + pub events: Vec, +} + +impl TimelineEventView { + pub fn from_domain(event: TimelineEvent) -> Self { + let TimelineEvent { + id, + entity_type, + entity_id, + action, + occurred_at, + title, + details, + tasting_notes, + slug, + roaster_slug, + brew_data, + } = event; + + let kind_label = match (entity_type.as_str(), action.as_str()) { + ("roaster", "added") => "Roaster Added", + ("roast", "added") => "Roast Added", + ("bag", "added") => "Bag Added", + ("bag", "finished") => "Bag Finished", + ("gear", "added") => "Gear Added", + ("brew", "brewed") => "Brewed", + ("cafe", "added") => "Cafe Added", + ("cup", "added") => "Cup", + _ => "Event", + }; + + let link = match (entity_type.as_str(), slug, roaster_slug) { + ("roaster", Some(slug), _) => format!("/roasters/{slug}"), + // Roasts, bags, brews, and cups link to the roast page when we have slug info + ("roast" | "bag" | "brew" | "cup", Some(slug), Some(roaster_slug)) => { + format!("/roasters/{roaster_slug}/roasts/{slug}") + } + ("cafe", Some(slug), _) => format!("/cafes/{slug}"), + ("cup", _, _) => "/cups".to_string(), + ("gear", _, _) => "/gear".to_string(), + ("brew", _, _) => "/brews".to_string(), + ("roaster", None, _) => format!("/roasters/{entity_id}"), + ("roast", None, _) => format!("/roasts/{entity_id}"), + _ => String::from("#"), + }; + + let (mapped_details, external_link) = Self::map_details(details); + + let tasting_notes = if entity_type == "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::>() + }) + .collect::>(); + Some(notes) + } else { + None + }; + + let brew_data_view = brew_data.map(|bd| TimelineBrewDataView { + bag_id: bd.bag_id, + grinder_id: bd.grinder_id, + brewer_id: bd.brewer_id, + filter_paper_id: bd.filter_paper_id, + coffee_weight: bd.coffee_weight, + grind_setting: bd.grind_setting, + water_volume: bd.water_volume, + water_temp: bd.water_temp, + }); + + Self { + id: id.to_string(), + kind_label, + date_label: occurred_at.format("%B %d, %Y").to_string(), + time_label: Some(occurred_at.format("%H:%M UTC").to_string()), + iso_timestamp: occurred_at.to_rfc3339(), + title, + link, + external_link, + details: mapped_details, + tasting_notes, + brew_data: brew_data_view, + } + } + + fn map_details( + details: Vec, + ) -> (Vec, Option) { + let mut mapped = Vec::new(); + let mut external_link = None; + for detail in details { + if detail.label.eq_ignore_ascii_case("homepage") + || detail.label.eq_ignore_ascii_case("website") + { + let trimmed = detail.value.trim(); + if !trimmed.is_empty() && trimmed != "—" { + external_link = Some(trimmed.to_string()); + } + } else if detail.label.eq_ignore_ascii_case("position") { + let trimmed = detail.value.trim(); + if !trimmed.is_empty() { + let display = trimmed + .strip_prefix("https://www.google.com/maps?q=") + .unwrap_or(trimmed); + mapped.push(TimelineEventDetailView { + label: detail.label, + value: display.to_string(), + link: Some(trimmed.to_string()), + }); + } + } else { + mapped.push(TimelineEventDetailView { + label: detail.label, + value: detail.value, + link: None, + }); + } + } + (mapped, external_link) + } +}