diff --git a/scripts/bootstrap-db.sh b/scripts/bootstrap-db.sh index f2f9836..e9eed67 100755 --- a/scripts/bootstrap-db.sh +++ b/scripts/bootstrap-db.sh @@ -60,7 +60,7 @@ fi ./target/debug/brewlog roast add \ --roaster-id "$(./target/debug/brewlog roaster list | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \ --name "Red Brick Espresso" \ - --origin "Blend" \ + --origin "Ethiopia, Colombia" \ --region "Multiple Origins" \ --producer "Various" \ --process "Washed, Natural" \ diff --git a/src/domain/countries.rs b/src/domain/countries.rs index e59ec80..13e4576 100644 --- a/src/domain/countries.rs +++ b/src/domain/countries.rs @@ -112,6 +112,34 @@ pub fn iso_to_flag_emoji(code: &str) -> String { .collect() } +/// Split a comma-separated origin string into trimmed, non-empty country names. +/// +/// Returns an empty `Vec` for `None`, empty, or whitespace-only input. +/// Single origins like `"Ethiopia"` yield `vec!["Ethiopia"]`. +/// Blends like `"Ethiopia, Colombia"` yield `vec!["Ethiopia", "Colombia"]`. +pub fn parse_origins(origin: Option<&str>) -> Vec<&str> { + match origin { + Some(s) if !s.trim().is_empty() => s + .split(',') + .map(str::trim) + .filter(|p| !p.is_empty()) + .collect(), + _ => Vec::new(), + } +} + +/// Resolve a comma-separated origin string to a space-separated flag emoji string. +/// +/// Unknown countries are silently skipped. Returns empty string if no countries resolve. +pub fn origins_to_flags(origin: Option<&str>) -> String { + let flags: Vec = parse_origins(origin) + .into_iter() + .filter_map(country_to_iso) + .map(iso_to_flag_emoji) + .collect(); + flags.join(" ") +} + #[cfg(test)] mod tests { use super::*; @@ -143,4 +171,62 @@ mod tests { assert_eq!(iso_to_flag_emoji("US"), "πŸ‡ΊπŸ‡Έ"); assert_eq!(iso_to_flag_emoji("ET"), "πŸ‡ͺπŸ‡Ή"); } + + #[test] + fn parse_origins_single() { + assert_eq!(parse_origins(Some("Ethiopia")), vec!["Ethiopia"]); + } + + #[test] + fn parse_origins_multiple() { + assert_eq!( + parse_origins(Some("Ethiopia, Colombia")), + vec!["Ethiopia", "Colombia"] + ); + } + + #[test] + fn parse_origins_trims_whitespace() { + assert_eq!( + parse_origins(Some(" Ethiopia , Colombia , Kenya ")), + vec!["Ethiopia", "Colombia", "Kenya"] + ); + } + + #[test] + fn parse_origins_empty_and_none() { + assert!(parse_origins(None).is_empty()); + assert!(parse_origins(Some("")).is_empty()); + assert!(parse_origins(Some(" ")).is_empty()); + } + + #[test] + fn parse_origins_trailing_comma() { + assert_eq!(parse_origins(Some("Ethiopia,")), vec!["Ethiopia"]); + } + + #[test] + fn origins_to_flags_single() { + let flags = origins_to_flags(Some("Ethiopia")); + assert_eq!(flags, iso_to_flag_emoji("ET")); + } + + #[test] + fn origins_to_flags_multiple() { + let flags = origins_to_flags(Some("Ethiopia, Colombia")); + let expected = format!("{} {}", iso_to_flag_emoji("ET"), iso_to_flag_emoji("CO")); + assert_eq!(flags, expected); + } + + #[test] + fn origins_to_flags_skips_unknown() { + let flags = origins_to_flags(Some("Ethiopia, Narnia, Colombia")); + let expected = format!("{} {}", iso_to_flag_emoji("ET"), iso_to_flag_emoji("CO")); + assert_eq!(flags, expected); + } + + #[test] + fn origins_to_flags_none() { + assert_eq!(origins_to_flags(None), ""); + } } diff --git a/src/infrastructure/ai.rs b/src/infrastructure/ai.rs index 781da67..f8e7bac 100644 --- a/src/infrastructure/ai.rs +++ b/src/infrastructure/ai.rs @@ -19,7 +19,7 @@ Return ONLY the JSON object, no other text."#; const ROAST_PROMPT: &str = r#"Extract coffee roast information from this input. Use web search to look up any details you cannot determine from the input alone (e.g. origin, region, producer, processing method, tasting notes). Return a JSON object with these fields (only include fields you can identify with confidence): - "roaster_name": the name of the roaster - "name": the name of this specific coffee/roast -- "origin": the country of origin of the coffee beans +- "origin": the country (or countries, comma-separated) of origin of the coffee beans (e.g. "Ethiopia" or "Ethiopia, Colombia") - "region": the region within the origin country - "producer": the farm, estate, or cooperative that produced the beans - "process": the processing method (e.g. Washed, Natural, Honey, Anaerobic) @@ -38,7 +38,7 @@ const SCAN_PROMPT: &str = r#"Extract both the coffee roaster and the roast infor }, "roast": { "name": "the name of this specific coffee/roast", - "origin": "the country of origin of the beans", + "origin": "the country (or countries, comma-separated) of origin of the beans (e.g. 'Ethiopia' or 'Ethiopia, Colombia')", "region": "the region within the origin country", "producer": "the farm, estate, or cooperative", "process": "processing method (e.g. Washed, Natural, Honey, Anaerobic)", diff --git a/src/infrastructure/repositories/analytics/stats.rs b/src/infrastructure/repositories/analytics/stats.rs index aea5912..a4afa31 100644 --- a/src/infrastructure/repositories/analytics/stats.rs +++ b/src/infrastructure/repositories/analytics/stats.rs @@ -64,10 +64,19 @@ impl StatsRepository for SqlStatsRepository { async fn roast_origin_counts(&self) -> Result, RepositoryError> { let rows = query_as::<_, CountryCount>( - r"SELECT origin as country, COUNT(*) as count - FROM roasts - WHERE origin IS NOT NULL AND origin != '' - GROUP BY origin + r"WITH RECURSIVE split(country, rest) AS ( + SELECT TRIM(SUBSTR(origin, 1, INSTR(origin || ',', ',') - 1)), + TRIM(SUBSTR(origin, INSTR(origin || ',', ',') + 1)) + FROM roasts + WHERE origin IS NOT NULL AND origin != '' + UNION ALL + SELECT TRIM(SUBSTR(rest, 1, INSTR(rest || ',', ',') - 1)), + TRIM(SUBSTR(rest, INSTR(rest || ',', ',') + 1)) + FROM split WHERE rest != '' + ) + SELECT country, COUNT(*) as count + FROM split WHERE country != '' + GROUP BY LOWER(country) ORDER BY count DESC", ) .fetch_all(&self.pool) @@ -108,17 +117,36 @@ impl StatsRepository for SqlStatsRepository { async fn roast_summary(&self) -> Result { let unique_origins: i64 = query_scalar( - r"SELECT COUNT(DISTINCT origin) FROM roasts - WHERE origin IS NOT NULL AND origin != ''", + r"WITH RECURSIVE split(country, rest) AS ( + SELECT TRIM(SUBSTR(origin, 1, INSTR(origin || ',', ',') - 1)), + TRIM(SUBSTR(origin, INSTR(origin || ',', ',') + 1)) + FROM roasts + WHERE origin IS NOT NULL AND origin != '' + UNION ALL + SELECT TRIM(SUBSTR(rest, 1, INSTR(rest || ',', ',') - 1)), + TRIM(SUBSTR(rest, INSTR(rest || ',', ',') + 1)) + FROM split WHERE rest != '' + ) + SELECT COUNT(DISTINCT LOWER(country)) FROM split WHERE country != ''", ) .fetch_one(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; let top_origin = query_as::<_, NameCount>( - r"SELECT origin as name, COUNT(*) as count FROM roasts - WHERE origin IS NOT NULL AND origin != '' - GROUP BY origin ORDER BY count DESC LIMIT 1", + r"WITH RECURSIVE split(country, rest) AS ( + SELECT TRIM(SUBSTR(origin, 1, INSTR(origin || ',', ',') - 1)), + TRIM(SUBSTR(origin, INSTR(origin || ',', ',') + 1)) + FROM roasts + WHERE origin IS NOT NULL AND origin != '' + UNION ALL + SELECT TRIM(SUBSTR(rest, 1, INSTR(rest || ',', ',') - 1)), + TRIM(SUBSTR(rest, INSTR(rest || ',', ',') + 1)) + FROM split WHERE rest != '' + ) + SELECT country as name, COUNT(*) as count + FROM split WHERE country != '' + GROUP BY LOWER(country) ORDER BY count DESC LIMIT 1", ) .fetch_optional(&self.pool) .await diff --git a/src/presentation/web/views/cups.rs b/src/presentation/web/views/cups.rs index 986a608..ca46117 100644 --- a/src/presentation/web/views/cups.rs +++ b/src/presentation/web/views/cups.rs @@ -86,10 +86,8 @@ impl CupDetailView { let mut map_entries: Vec<(&str, u32)> = Vec::new(); map_entries.push((cafe.country.as_str(), 3)); - if let Some(ref o) = roast.origin - && !o.is_empty() - { - map_entries.push((o.as_str(), 2)); + for o in crate::domain::countries::parse_origins(roast.origin.as_deref()) { + map_entries.push((o, 2)); } map_entries.push((roaster.country.as_str(), 1)); let (map_countries, map_max) = build_map_data(&map_entries); diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index f2eeb21..ace9b3f 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -387,13 +387,11 @@ pub(crate) struct CoffeeInfo { /// 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}; + use crate::domain::countries::origins_to_flags; 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 origin_flag = origins_to_flags(roast.origin.as_deref()); let notes = tasting_notes::parse_and_categorize(&roast.tasting_notes); @@ -449,14 +447,17 @@ 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. +/// Origin gets weight 2, roaster country gets weight 1. Comma-separated +/// origins (blends) are split so each country is highlighted on the map. +/// 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) { + use crate::domain::countries::parse_origins; + let mut entries: Vec<(&str, u32)> = Vec::new(); - if let Some(o) = origin.filter(|o| !o.is_empty()) { + for o in parse_origins(origin) { entries.push((o, 2)); } entries.push((roaster_country, 1)); diff --git a/src/presentation/web/views/roasts.rs b/src/presentation/web/views/roasts.rs index 619d6ff..214637b 100644 --- a/src/presentation/web/views/roasts.rs +++ b/src/presentation/web/views/roasts.rs @@ -1,4 +1,4 @@ -use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; +use crate::domain::countries::origins_to_flags; use crate::domain::roasters::Roaster; use crate::domain::roasts::{Roast, RoastWithRoaster}; @@ -59,11 +59,7 @@ impl RoastView { } else { roaster_name.to_string() }; - let origin_flag = origin - .as_deref() - .and_then(country_to_iso) - .map(iso_to_flag_emoji) - .unwrap_or_default(); + let origin_flag = origins_to_flags(origin.as_deref()); let origin = origin.unwrap_or_else(|| "β€”".to_string()); let region = region.unwrap_or_else(|| "β€”".to_string()); let producer = producer.unwrap_or_else(|| "β€”".to_string());