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
This commit is contained in:
Jon Seager 2026-02-06 16:26:29 +00:00
parent dcaac1ee9c
commit 44174b4361
No known key found for this signature in database
7 changed files with 514 additions and 4 deletions

View file

@ -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,
};

View file

@ -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<String>,
pub tasting_notes: Vec<TastingNoteView>,
}
impl RoastView {
@ -64,6 +66,7 @@ impl RoastView {
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
})
.map(|note| tasting_notes::categorize(&note))
.collect();
let created_date = created_at.format("%Y-%m-%d").to_string();
let created_time = created_at.format("%H:%M").to_string();

View file

@ -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<NoteCategory> {
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");
}
}

View file

@ -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<String>,
pub details: Vec<TimelineEventDetailView>,
pub subtitle: Option<String>,
pub tasting_notes: Option<Vec<String>>,
pub tasting_notes: Option<Vec<TastingNoteView>>,
pub brew_data: Option<TimelineBrewDataView>,
}
@ -112,6 +113,7 @@ impl TimelineEventView {
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
})
.map(|note| tasting_notes::categorize(&note))
.collect::<Vec<_>>();
Some(notes)
} else {

View file

@ -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 {

View file

@ -66,7 +66,7 @@
{% if !roast.tasting_notes.is_empty() %}
<div class="flex flex-wrap gap-1 md:justify-start justify-end">
{% for note in roast.tasting_notes %}
<span class="pill pill-muted">{{ note }}</span>
<span class="{{ note.pill_class }}">{{ note.label }}</span>
{% endfor %}
</div>
{% else %}

View file

@ -88,7 +88,11 @@
{% if !notes.is_empty() %}
<div class="flex justify-between gap-2">
<dt class="shrink-0 font-medium text-text-muted">Tasting Notes</dt>
<dd class="text-right">{{ notes.join(", ") }}</dd>
<dd class="flex flex-wrap gap-1 justify-end">
{% for note in notes %}
<span class="{{ note.pill_class }}">{{ note.label }}</span>
{% endfor %}
</dd>
</div>
{% endif %}
{% endif %}