fix: allow multiple origins for mixed roasts
This commit is contained in:
parent
ee145fffe6
commit
6a2bd5e563
7 changed files with 138 additions and 29 deletions
|
|
@ -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" \
|
||||||
|
|
|
||||||
|
|
@ -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), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)",
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
FROM roasts
|
SELECT TRIM(SUBSTR(origin, 1, INSTR(origin || ',', ',') - 1)),
|
||||||
WHERE origin IS NOT NULL AND origin != ''
|
TRIM(SUBSTR(origin, INSTR(origin || ',', ',') + 1))
|
||||||
GROUP BY origin
|
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",
|
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 (
|
||||||
WHERE origin IS NOT NULL AND origin != ''
|
SELECT TRIM(SUBSTR(origin, 1, INSTR(origin || ',', ',') - 1)),
|
||||||
GROUP BY origin ORDER BY count DESC LIMIT 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)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue