From 44174b4361f0065b348c77c2e95b21e6ad436597 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 6 Feb 2026 16:26:29 +0000 Subject: [PATCH] feat(ui): colour-code tasting note pills by SCA wheel category Add view-layer mapping that categorises tasting notes into 9 colour families (floral, fruity, citrus, sweet, nutty, spice, roasted, sour, vegetal) based on the SCA Coffee Tasting Wheel. Matching uses case-insensitive exact lookup against ~90 known terms, then substring fallback, with pill-muted as the default for unrecognised notes. - Add tasting_notes module with NoteCategory enum and categorize() - Add 9 pill CSS variants with light and dark mode support - Update roast list and timeline templates to render coloured pills --- src/presentation/web/views/mod.rs | 2 + src/presentation/web/views/roasts.rs | 5 +- src/presentation/web/views/tasting_notes.rs | 398 ++++++++++++++++++++ src/presentation/web/views/timeline.rs | 4 +- static/css/input.css | 101 +++++ templates/partials/lists/roast_list.html | 2 +- templates/partials/timeline_month.html | 6 +- 7 files changed, 514 insertions(+), 4 deletions(-) create mode 100644 src/presentation/web/views/tasting_notes.rs diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index 0b90b4e..8f7e17f 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -5,6 +5,7 @@ mod cups; mod gear; mod roasters; mod roasts; +pub mod tasting_notes; mod timeline; pub use bags::{BagOptionView, BagView}; @@ -14,6 +15,7 @@ pub use cups::CupView; pub use gear::{GearOptionView, GearView}; pub use roasters::{RoasterOptionView, RoasterView}; pub use roasts::{RoastOptionView, RoastView}; +pub use tasting_notes::TastingNoteView; pub use timeline::{ TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView, }; diff --git a/src/presentation/web/views/roasts.rs b/src/presentation/web/views/roasts.rs index 912e3fa..440a599 100644 --- a/src/presentation/web/views/roasts.rs +++ b/src/presentation/web/views/roasts.rs @@ -1,5 +1,7 @@ use crate::domain::roasts::{Roast, RoastWithRoaster}; +use super::tasting_notes::{self, TastingNoteView}; + pub struct RoastView { pub id: String, pub full_id: String, @@ -13,7 +15,7 @@ pub struct RoastView { pub created_date: String, pub created_time: String, pub created_at_sort_key: i64, - pub tasting_notes: Vec, + pub tasting_notes: Vec, } impl RoastView { @@ -64,6 +66,7 @@ impl RoastView { .filter(|segment| !segment.is_empty()) .collect::>() }) + .map(|note| tasting_notes::categorize(¬e)) .collect(); let created_date = created_at.format("%Y-%m-%d").to_string(); let created_time = created_at.format("%H:%M").to_string(); diff --git a/src/presentation/web/views/tasting_notes.rs b/src/presentation/web/views/tasting_notes.rs new file mode 100644 index 0000000..dcf7e80 --- /dev/null +++ b/src/presentation/web/views/tasting_notes.rs @@ -0,0 +1,398 @@ +/// Maps tasting note strings to colour categories based on the SCA Coffee +/// Tasting Wheel. Unknown notes fall back to the neutral `pill-muted` style. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NoteCategory { + Floral, + Fruity, + Citrus, + Sweet, + Nutty, + Spice, + Roasted, + Sour, + Vegetal, + Default, +} + +impl NoteCategory { + const fn pill_class(self) -> &'static str { + match self { + Self::Floral => "pill pill-floral", + Self::Fruity => "pill pill-fruity", + Self::Citrus => "pill pill-citrus", + Self::Sweet => "pill pill-sweet", + Self::Nutty => "pill pill-nutty", + Self::Spice => "pill pill-spice", + Self::Roasted => "pill pill-roasted", + Self::Sour => "pill pill-sour", + Self::Vegetal => "pill pill-vegetal", + Self::Default => "pill pill-muted", + } + } +} + +#[derive(Clone)] +pub struct TastingNoteView { + pub label: String, + pub pill_class: &'static str, +} + +/// Categorise a tasting note string and return a view with the appropriate +/// pill class. Matching is case-insensitive: first an exact match against +/// known SCA wheel terms, then a substring scan for common keywords. +pub fn categorize(note: &str) -> TastingNoteView { + let lower = note.to_lowercase(); + let category = exact_match(&lower).unwrap_or_else(|| substring_match(&lower)); + TastingNoteView { + label: note.to_string(), + pill_class: category.pill_class(), + } +} + +// ── Exact matches ──────────────────────────────────────────────────── + +fn exact_match(lower: &str) -> Option { + EXACT_MATCHES + .iter() + .find(|(term, _)| *term == lower) + .map(|(_, cat)| *cat) +} + +use NoteCategory::{Citrus, Floral, Fruity, Nutty, Roasted, Sour, Spice, Sweet, Vegetal}; + +const EXACT_MATCHES: &[(&str, NoteCategory)] = &[ + // Floral + ("floral", Floral), + ("jasmine", Floral), + ("rose", Floral), + ("chamomile", Floral), + ("lavender", Floral), + ("hibiscus", Floral), + ("elderflower", Floral), + ("violet", Floral), + ("honeysuckle", Floral), + ("orange blossom", Floral), + // Berry + ("berry", Fruity), + ("blueberry", Fruity), + ("strawberry", Fruity), + ("raspberry", Fruity), + ("blackberry", Fruity), + ("cranberry", Fruity), + ("boysenberry", Fruity), + ("currant", Fruity), + ("blackcurrant", Fruity), + ("redcurrant", Fruity), + ("red currant", Fruity), + ("black currant", Fruity), + // Dried fruit + ("raisin", Fruity), + ("prune", Fruity), + ("fig", Fruity), + ("date", Fruity), + ("dried fruit", Fruity), + // Other fruit + ("cherry", Fruity), + ("pomegranate", Fruity), + ("pineapple", Fruity), + ("grape", Fruity), + ("apple", Fruity), + ("red apple", Fruity), + ("green apple", Fruity), + ("peach", Fruity), + ("pear", Fruity), + ("plum", Fruity), + ("apricot", Fruity), + ("mango", Fruity), + ("papaya", Fruity), + ("guava", Fruity), + ("passion fruit", Fruity), + ("passionfruit", Fruity), + ("coconut", Fruity), + ("melon", Fruity), + ("watermelon", Fruity), + ("yellow fruit", Fruity), + ("stone fruit", Fruity), + ("tropical", Fruity), + ("tropical fruit", Fruity), + ("fruit", Fruity), + ("fruity", Fruity), + ("juicy", Fruity), + ("tomato", Fruity), + ("rhubarb", Fruity), + // Citrus + ("citrus", Citrus), + ("lemon", Citrus), + ("lime", Citrus), + ("orange", Citrus), + ("grapefruit", Citrus), + ("bergamot", Citrus), + ("tangerine", Citrus), + ("mandarin", Citrus), + ("yuzu", Citrus), + ("clementine", Citrus), + ("zesty", Citrus), + ("citric", Citrus), + ("citric acid", Citrus), + // Sweet + ("sweet", Sweet), + ("caramel", Sweet), + ("honey", Sweet), + ("vanilla", Sweet), + ("vanillin", Sweet), + ("brown sugar", Sweet), + ("chocolate", Sweet), + ("dark chocolate", Sweet), + ("milk chocolate", Sweet), + ("white chocolate", Sweet), + ("toffee", Sweet), + ("butterscotch", Sweet), + ("maple", Sweet), + ("maple syrup", Sweet), + ("molasses", Sweet), + ("caramelized", Sweet), + ("sugar cane", Sweet), + ("sugarcane", Sweet), + ("candy", Sweet), + ("marshmallow", Sweet), + ("nougat", Sweet), + ("lychee", Sweet), + ("syrupy", Sweet), + ("black tea", Sweet), + ("tea", Sweet), + ("nasturtium", Sweet), + ("fudge", Sweet), + // Nutty / Cocoa + ("nutty", Nutty), + ("hazelnut", Nutty), + ("almond", Nutty), + ("peanut", Nutty), + ("peanuts", Nutty), + ("walnut", Nutty), + ("pecan", Nutty), + ("macadamia", Nutty), + ("pistachio", Nutty), + ("cashew", Nutty), + ("cocoa", Nutty), + ("cacao", Nutty), + ("praline", Nutty), + ("marzipan", Nutty), + ("roasted almond", Nutty), + ("roasted nuts", Nutty), + // Spice + ("spice", Spice), + ("spicy", Spice), + ("cinnamon", Spice), + ("nutmeg", Spice), + ("clove", Spice), + ("anise", Spice), + ("star anise", Spice), + ("cardamom", Spice), + ("ginger", Spice), + ("pepper", Spice), + ("black pepper", Spice), + ("pink pepper", Spice), + ("allspice", Spice), + ("brown spice", Spice), + ("pungent", Spice), + // Roasted + ("roasted", Roasted), + ("smoky", Roasted), + ("tobacco", Roasted), + ("pipe tobacco", Roasted), + ("ashy", Roasted), + ("burnt", Roasted), + ("charred", Roasted), + ("malt", Roasted), + ("grain", Roasted), + ("cereal", Roasted), + ("toast", Roasted), + ("toasted", Roasted), + ("roasty", Roasted), + ("dark roast", Roasted), + // Sour / Fermented + ("sour", Sour), + ("fermented", Sour), + ("winey", Sour), + ("wine", Sour), + ("whiskey", Sour), + ("boozy", Sour), + ("acetic", Sour), + ("acetic acid", Sour), + ("malic acid", Sour), + ("mead", Sour), + ("tart", Sour), + ("tangy", Sour), + ("vinous", Sour), + ("overripe", Sour), + // Green / Vegetal + ("green", Vegetal), + ("vegetal", Vegetal), + ("vegetative", Vegetal), + ("herbal", Vegetal), + ("grassy", Vegetal), + ("hay", Vegetal), + ("herb-like", Vegetal), + ("fresh", Vegetal), + ("earthy", Vegetal), + ("woody", Vegetal), + ("cedar", Vegetal), + ("pine", Vegetal), + ("mint", Vegetal), + ("eucalyptus", Vegetal), + ("sage", Vegetal), + ("thyme", Vegetal), + ("basil", Vegetal), + ("yoghurt", Vegetal), + ("yogurt", Vegetal), + ("cream", Vegetal), +]; + +// ── Substring fallback ─────────────────────────────────────────────── + +fn substring_match(lower: &str) -> NoteCategory { + // Order: specific before general to avoid false positives. + const KEYWORDS: &[(&str, NoteCategory)] = &[ + // Floral + ("floral", Floral), + ("blossom", Floral), + ("flower", Floral), + ("jasmine", Floral), + ("rose", Floral), + // Fruity / Berry + ("berry", Fruity), + ("cherry", Fruity), + ("plum", Fruity), + ("peach", Fruity), + ("apricot", Fruity), + ("mango", Fruity), + ("grape", Fruity), + ("apple", Fruity), + ("pear", Fruity), + ("melon", Fruity), + ("fruit", Fruity), + ("tropical", Fruity), + ("juicy", Fruity), + ("raisin", Fruity), + ("prune", Fruity), + ("fig", Fruity), + // Citrus + ("citrus", Citrus), + ("lemon", Citrus), + ("lime", Citrus), + ("grapefruit", Citrus), + ("bergamot", Citrus), + ("orange", Citrus), + ("tangerine", Citrus), + ("zesty", Citrus), + // Sweet / Chocolate + ("chocolate", Sweet), + ("caramel", Sweet), + ("honey", Sweet), + ("vanilla", Sweet), + ("toffee", Sweet), + ("butterscotch", Sweet), + ("maple", Sweet), + ("molasses", Sweet), + ("sugar", Sweet), + ("candy", Sweet), + ("syrup", Sweet), + ("sweet", Sweet), + // Nutty + ("nut", Nutty), + ("cocoa", Nutty), + ("cacao", Nutty), + ("praline", Nutty), + ("marzipan", Nutty), + ("almond", Nutty), + // Spice + ("cinnamon", Spice), + ("clove", Spice), + ("cardamom", Spice), + ("ginger", Spice), + ("pepper", Spice), + ("spice", Spice), + ("spicy", Spice), + // Roasted + ("smoke", Roasted), + ("smoky", Roasted), + ("tobacco", Roasted), + ("roast", Roasted), + ("toast", Roasted), + ("malt", Roasted), + ("grain", Roasted), + ("ash", Roasted), + ("burnt", Roasted), + ("charred", Roasted), + // Sour / Fermented + ("wine", Sour), + ("ferment", Sour), + ("tart", Sour), + ("sour", Sour), + ("tangy", Sour), + ("vinous", Sour), + ("boozy", Sour), + // Vegetal + ("herbal", Vegetal), + ("herb", Vegetal), + ("grass", Vegetal), + ("green", Vegetal), + ("earthy", Vegetal), + ("woody", Vegetal), + ("cedar", Vegetal), + ("pine", Vegetal), + ("mint", Vegetal), + ]; + + for (keyword, category) in KEYWORDS { + if lower.contains(keyword) { + return *category; + } + } + + NoteCategory::Default +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_match_known_notes() { + assert_eq!(categorize("Jasmine").pill_class, "pill pill-floral"); + assert_eq!(categorize("Blueberry").pill_class, "pill pill-fruity"); + assert_eq!(categorize("Lemon").pill_class, "pill pill-citrus"); + assert_eq!(categorize("Caramel").pill_class, "pill pill-sweet"); + assert_eq!(categorize("Hazelnut").pill_class, "pill pill-nutty"); + assert_eq!(categorize("Cinnamon").pill_class, "pill pill-spice"); + assert_eq!(categorize("Smoky").pill_class, "pill pill-roasted"); + assert_eq!(categorize("Winey").pill_class, "pill pill-sour"); + assert_eq!(categorize("Herbal").pill_class, "pill pill-vegetal"); + } + + #[test] + fn case_insensitive() { + assert_eq!(categorize("JASMINE").pill_class, "pill pill-floral"); + assert_eq!(categorize("dark chocolate").pill_class, "pill pill-sweet"); + } + + #[test] + fn substring_fallback() { + assert_eq!(categorize("Wild Blueberry").pill_class, "pill pill-fruity"); + assert_eq!(categorize("Citrus Zest").pill_class, "pill pill-citrus"); + assert_eq!(categorize("Roasted Almond").pill_class, "pill pill-nutty"); + } + + #[test] + fn unknown_note_falls_back_to_muted() { + assert_eq!(categorize("Umami").pill_class, "pill pill-muted"); + assert_eq!(categorize("Complex").pill_class, "pill pill-muted"); + } + + #[test] + fn preserves_original_label() { + let view = categorize("Dark Chocolate"); + assert_eq!(view.label, "Dark Chocolate"); + } +} diff --git a/src/presentation/web/views/timeline.rs b/src/presentation/web/views/timeline.rs index 3666246..8b2dd4b 100644 --- a/src/presentation/web/views/timeline.rs +++ b/src/presentation/web/views/timeline.rs @@ -1,6 +1,7 @@ use crate::domain::timeline::{TimelineEvent, TimelineEventDetail}; use super::relative_date; +use super::tasting_notes::{self, TastingNoteView}; /// Returns `true` if the value is empty or just an em-dash placeholder. fn is_blank(value: &str) -> bool { @@ -54,7 +55,7 @@ pub struct TimelineEventView { pub external_link: Option, pub details: Vec, pub subtitle: Option, - pub tasting_notes: Option>, + pub tasting_notes: Option>, pub brew_data: Option, } @@ -112,6 +113,7 @@ impl TimelineEventView { .filter(|segment| !segment.is_empty()) .collect::>() }) + .map(|note| tasting_notes::categorize(¬e)) .collect::>(); Some(notes) } else { diff --git a/static/css/input.css b/static/css/input.css index eb6dac9..3ae668a 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -168,6 +168,107 @@ a { background-color: rgba(5, 150, 105, 0.1); } +/* Tasting-note colour categories (SCA wheel) */ + +.pill-floral { + border-color: rgba(236, 72, 153, 0.3); + color: #9d174d; + background-color: #fdf2f8; +} + +.pill-fruity { + border-color: rgba(220, 38, 38, 0.3); + color: #991b1b; + background-color: #fef2f2; +} + +.pill-citrus { + border-color: rgba(245, 158, 11, 0.3); + color: #92400e; + background-color: #fffbeb; +} + +.pill-sweet { + border-color: rgba(234, 88, 12, 0.3); + color: #9a3412; + background-color: #fff7ed; +} + +.pill-nutty { + border-color: rgba(161, 98, 7, 0.3); + color: #713f12; + background-color: #fefce8; +} + +.pill-spice { + border-color: rgba(190, 18, 60, 0.3); + color: #9f1239; + background-color: #fff1f2; +} + +.pill-roasted { + border-color: rgba(120, 113, 108, 0.3); + color: #44403c; + background-color: #f5f5f4; +} + +.pill-sour { + border-color: rgba(101, 163, 13, 0.3); + color: #3f6212; + background-color: #f7fee7; +} + +.pill-vegetal { + border-color: rgba(5, 150, 105, 0.3); + color: #065f46; + background-color: #ecfdf5; +} + +[data-theme="dark"] .pill-floral { + color: #f9a8d4; + background-color: rgba(236, 72, 153, 0.1); +} + +[data-theme="dark"] .pill-fruity { + color: #fca5a5; + background-color: rgba(220, 38, 38, 0.1); +} + +[data-theme="dark"] .pill-citrus { + color: #fcd34d; + background-color: rgba(245, 158, 11, 0.1); +} + +[data-theme="dark"] .pill-sweet { + color: #fdba74; + background-color: rgba(234, 88, 12, 0.1); +} + +[data-theme="dark"] .pill-nutty { + color: #fde68a; + background-color: rgba(161, 98, 7, 0.1); +} + +[data-theme="dark"] .pill-spice { + color: #fda4af; + background-color: rgba(190, 18, 60, 0.1); +} + +[data-theme="dark"] .pill-roasted { + color: #a8a29e; + background-color: rgba(120, 113, 108, 0.1); +} + +[data-theme="dark"] .pill-sour { + color: #bef264; + background-color: rgba(101, 163, 13, 0.1); +} + +[data-theme="dark"] .pill-vegetal { + color: #6ee7b7; + background-color: rgba(5, 150, 105, 0.1); +} + /* ── Tabs ──────────────────────────────────────────────────────── */ .tab { diff --git a/templates/partials/lists/roast_list.html b/templates/partials/lists/roast_list.html index e1fc871..d686233 100644 --- a/templates/partials/lists/roast_list.html +++ b/templates/partials/lists/roast_list.html @@ -66,7 +66,7 @@ {% if !roast.tasting_notes.is_empty() %}
{% for note in roast.tasting_notes %} - {{ note }} + {{ note.label }} {% endfor %}
{% else %} diff --git a/templates/partials/timeline_month.html b/templates/partials/timeline_month.html index 5417f5d..25dd3c1 100644 --- a/templates/partials/timeline_month.html +++ b/templates/partials/timeline_month.html @@ -88,7 +88,11 @@ {% if !notes.is_empty() %}
Tasting Notes
-
{{ notes.join(", ") }}
+
+ {% for note in notes %} + {{ note.label }} + {% endfor %} +
{% endif %} {% endif %}