fix: allow multiple origins for mixed roasts

This commit is contained in:
Jon Seager 2026-02-24 11:03:20 +00:00
parent ee145fffe6
commit 6a2bd5e563
No known key found for this signature in database
7 changed files with 138 additions and 29 deletions

View file

@ -60,7 +60,7 @@ fi
./target/debug/brewlog roast add \ ./target/debug/brewlog roast add \
--roaster-id "$(./target/debug/brewlog roaster list | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \ --roaster-id "$(./target/debug/brewlog roaster list | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \
--name "Red Brick Espresso" \ --name "Red Brick Espresso" \
--origin "Blend" \ --origin "Ethiopia, Colombia" \
--region "Multiple Origins" \ --region "Multiple Origins" \
--producer "Various" \ --producer "Various" \
--process "Washed, Natural" \ --process "Washed, Natural" \

View file

@ -112,6 +112,34 @@ pub fn iso_to_flag_emoji(code: &str) -> String {
.collect() .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<String> = parse_origins(origin)
.into_iter()
.filter_map(country_to_iso)
.map(iso_to_flag_emoji)
.collect();
flags.join(" ")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -143,4 +171,62 @@ mod tests {
assert_eq!(iso_to_flag_emoji("US"), "🇺🇸"); assert_eq!(iso_to_flag_emoji("US"), "🇺🇸");
assert_eq!(iso_to_flag_emoji("ET"), "🇪🇹"); 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), "");
}
} }

View file

@ -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): 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 - "roaster_name": the name of the roaster
- "name": the name of this specific coffee/roast - "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 - "region": the region within the origin country
- "producer": the farm, estate, or cooperative that produced the beans - "producer": the farm, estate, or cooperative that produced the beans
- "process": the processing method (e.g. Washed, Natural, Honey, Anaerobic) - "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": { "roast": {
"name": "the name of this specific coffee/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", "region": "the region within the origin country",
"producer": "the farm, estate, or cooperative", "producer": "the farm, estate, or cooperative",
"process": "processing method (e.g. Washed, Natural, Honey, Anaerobic)", "process": "processing method (e.g. Washed, Natural, Honey, Anaerobic)",

View file

@ -64,10 +64,19 @@ impl StatsRepository for SqlStatsRepository {
async fn roast_origin_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError> { async fn roast_origin_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError> {
let rows = query_as::<_, CountryCount>( let rows = query_as::<_, CountryCount>(
r"SELECT origin as country, COUNT(*) as count r"WITH RECURSIVE split(country, rest) AS (
SELECT TRIM(SUBSTR(origin, 1, INSTR(origin || ',', ',') - 1)),
TRIM(SUBSTR(origin, INSTR(origin || ',', ',') + 1))
FROM roasts FROM roasts
WHERE origin IS NOT NULL AND origin != '' WHERE origin IS NOT NULL AND origin != ''
GROUP BY 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", ORDER BY count DESC",
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
@ -108,17 +117,36 @@ impl StatsRepository for SqlStatsRepository {
async fn roast_summary(&self) -> Result<RoastSummaryStats, RepositoryError> { async fn roast_summary(&self) -> Result<RoastSummaryStats, RepositoryError> {
let unique_origins: i64 = query_scalar( let unique_origins: i64 = query_scalar(
r"SELECT COUNT(DISTINCT origin) FROM roasts r"WITH RECURSIVE split(country, rest) AS (
WHERE origin IS NOT NULL AND origin != ''", 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) .fetch_one(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
let top_origin = query_as::<_, NameCount>( let top_origin = query_as::<_, NameCount>(
r"SELECT origin as name, COUNT(*) as count FROM roasts 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 != '' WHERE origin IS NOT NULL AND origin != ''
GROUP BY origin ORDER BY count DESC LIMIT 1", 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) .fetch_optional(&self.pool)
.await .await

View file

@ -86,10 +86,8 @@ impl CupDetailView {
let mut map_entries: Vec<(&str, u32)> = Vec::new(); let mut map_entries: Vec<(&str, u32)> = Vec::new();
map_entries.push((cafe.country.as_str(), 3)); map_entries.push((cafe.country.as_str(), 3));
if let Some(ref o) = roast.origin for o in crate::domain::countries::parse_origins(roast.origin.as_deref()) {
&& !o.is_empty() map_entries.push((o, 2));
{
map_entries.push((o.as_str(), 2));
} }
map_entries.push((roaster.country.as_str(), 1)); map_entries.push((roaster.country.as_str(), 1));
let (map_countries, map_max) = build_map_data(&map_entries); let (map_countries, map_max) = build_map_data(&map_entries);

View file

@ -387,13 +387,11 @@ pub(crate) struct CoffeeInfo {
/// Build coffee info fields from a roast, using em dash for empty/missing values. /// 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 { 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 em_dash = "\u{2014}".to_string();
let origin = roast.origin.clone().unwrap_or_default(); let origin = roast.origin.clone().unwrap_or_default();
let origin_flag = country_to_iso(&origin) let origin_flag = origins_to_flags(roast.origin.as_deref());
.map(iso_to_flag_emoji)
.unwrap_or_default();
let notes = tasting_notes::parse_and_categorize(&roast.tasting_notes); 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. /// Build origin + roaster country map data with standard legend entries.
/// ///
/// Origin gets weight 2, roaster country gets weight 1. Returns the /// Origin gets weight 2, roaster country gets weight 1. Comma-separated
/// `(data-countries, data-max, legend_entries)` tuple used by detail pages. /// 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( pub(crate) fn build_origin_roaster_map(
origin: Option<&str>, origin: Option<&str>,
roaster_country: &str, roaster_country: &str,
) -> (String, u32, Vec<LegendEntry>) { ) -> (String, u32, Vec<LegendEntry>) {
use crate::domain::countries::parse_origins;
let mut entries: Vec<(&str, u32)> = Vec::new(); 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((o, 2));
} }
entries.push((roaster_country, 1)); entries.push((roaster_country, 1));

View file

@ -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::roasters::Roaster;
use crate::domain::roasts::{Roast, RoastWithRoaster}; use crate::domain::roasts::{Roast, RoastWithRoaster};
@ -59,11 +59,7 @@ impl RoastView {
} else { } else {
roaster_name.to_string() roaster_name.to_string()
}; };
let origin_flag = origin let origin_flag = origins_to_flags(origin.as_deref());
.as_deref()
.and_then(country_to_iso)
.map(iso_to_flag_emoji)
.unwrap_or_default();
let origin = origin.unwrap_or_else(|| "".to_string()); let origin = origin.unwrap_or_else(|| "".to_string());
let region = region.unwrap_or_else(|| "".to_string()); let region = region.unwrap_or_else(|| "".to_string());
let producer = producer.unwrap_or_else(|| "".to_string()); let producer = producer.unwrap_or_else(|| "".to_string());