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 \
--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" \

View file

@ -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<String> = 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), "");
}
}

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):
- "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)",

View file

@ -64,10 +64,19 @@ impl StatsRepository for SqlStatsRepository {
async fn roast_origin_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError> {
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
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",
)
.fetch_all(&self.pool)
@ -108,17 +117,36 @@ impl StatsRepository for SqlStatsRepository {
async fn roast_summary(&self) -> Result<RoastSummaryStats, RepositoryError> {
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
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 != ''
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)
.await

View file

@ -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);

View file

@ -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<LegendEntry>) {
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));

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::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());