mod bags; mod brews; mod cafes; mod cups; mod gear; mod roasters; mod roasts; pub mod tasting_notes; mod timeline; pub use bags::{BagDetailView, BagOptionView, BagView}; pub use brews::{BrewDefaultsView, BrewDetailView, BrewView, QuickNoteView}; pub use cafes::{CafeDetailView, CafeOptionView, CafeView, NearbyCafeView}; pub use cups::{CupDetailView, CupView}; pub use gear::{GearDetailView, GearOptionView, GearView}; pub use roasters::{RoasterDetailView, RoasterOptionView, RoasterView}; pub use roasts::{RoastDetailView, RoastOptionView, RoastView}; pub use tasting_notes::TastingNoteView; pub use timeline::{ TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView, }; #[derive(Default)] pub struct StatsView { pub brews: u64, pub roasts: u64, pub roasters: u64, pub cups: u64, pub cafes: u64, pub bags: u64, } impl StatsView { pub fn is_empty(&self) -> bool { self.brews == 0 && self.roasts == 0 && self.roasters == 0 && self.cups == 0 && self.cafes == 0 && self.bags == 0 } } pub struct StatCard { pub icon: &'static str, pub value: String, pub label: &'static str, } use chrono::{DateTime, Utc}; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; fn relative_date(dt: DateTime) -> String { crate::domain::formatting::format_relative_time(dt, Utc::now()) } 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: String, fragment_path: String, request: ListRequest, search: Option, } impl ListNavigator { pub fn new( base_path: impl Into, fragment_path: impl Into, request: ListRequest, search: Option, ) -> Self { Self { base_path: base_path.into(), fragment_path: fragment_path.into(), 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() ) } /// Returns the full URL prefix for search: `{path}?{query_base}&q=` or `{path}&{query_base}&q=` /// depending on whether the base path already contains query parameters. pub fn search_href_prefix(&self) -> String { let sep = if self.base_path.contains('?') { '&' } else { '?' }; format!("{}{sep}{}&q=", self.base_path, self.search_query_base()) } fn build_href(&self, path: &str, request: ListRequest) -> String { let qs = self.build_query_string(request); if let Some((base, fragment)) = path.split_once('#') { let sep = if base.contains('?') { '&' } else { '?' }; format!("{base}{sep}{qs}#{fragment}") } else { let sep = if path.contains('?') { '&' } else { '?' }; format!("{path}{sep}{qs}") } } 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) } } /// A label + opacity pair for map legend entries on detail pages. pub struct LegendEntry { pub label: &'static str, pub opacity: &'static str, } /// Shared coffee info fields extracted from a `Roast` for detail pages. pub(crate) struct CoffeeInfo { pub origin: String, pub origin_flag: String, pub region: String, pub producer: String, pub process: String, pub tasting_notes: Vec, } /// Build coffee info fields from a roast, using em dash for empty/missing values. pub(crate) fn build_coffee_info(roast: &crate::domain::roasts::Roast) -> CoffeeInfo { use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; let em_dash = "\u{2014}".to_string(); let origin = roast.origin.clone().unwrap_or_default(); let origin_flag = country_to_iso(&origin) .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(); CoffeeInfo { origin: if origin.is_empty() { em_dash.clone() } else { origin }, origin_flag, region: roast .region .clone() .filter(|s| !s.is_empty()) .unwrap_or(em_dash.clone()), producer: roast .producer .clone() .filter(|s| !s.is_empty()) .unwrap_or(em_dash.clone()), process: roast .process .clone() .filter(|s| !s.is_empty()) .unwrap_or(em_dash), tasting_notes: notes, } } /// Shared roaster info fields extracted from a `Roaster` for detail pages. pub(crate) struct RoasterInfo { pub country: String, pub country_flag: String, pub city: Option, pub homepage: Option, } /// Build roaster info fields from a roaster. pub(crate) fn build_roaster_info(roaster: &crate::domain::roasters::Roaster) -> RoasterInfo { use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; let country_flag = country_to_iso(&roaster.country) .map(iso_to_flag_emoji) .unwrap_or_default(); RoasterInfo { country: roaster.country.clone(), country_flag, city: roaster.city.clone(), homepage: roaster.homepage.clone(), } } /// Build `data-countries` and `data-max` values for the world-map component. /// /// Accepts `(country_name, weight)` pairs where higher weights render darker. /// Resolves country names to ISO codes, deduplicates by keeping the highest weight /// per ISO code, and returns the attribute string and max value. pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) { use std::collections::HashMap; use crate::domain::countries::country_to_iso; let mut iso_weights: HashMap<&str, u32> = HashMap::new(); for &(country_name, weight) in entries { if country_name.is_empty() { continue; } if let Some(iso) = country_to_iso(country_name) { let entry = iso_weights.entry(iso).or_insert(0); *entry = (*entry).max(weight); } } let mut max = 0u32; let parts: Vec = iso_weights .iter() .map(|(iso, &w)| { max = max.max(w); format!("{iso}:{w}") }) .collect(); (parts.join(","), max) }