feat(home): add inline detail subtitles to timeline preview cards

Show contextual details inline with event titles on home page timeline
cards. Each event type picks relevant fields: brew shows roaster and
brewer, roast shows roaster and origin, cafe shows city and country,
cup shows roaster and cafe, others show first three detail values.
This commit is contained in:
Jon Seager 2026-02-04 19:14:30 +00:00
parent cdf90d1ff3
commit 939743a224
No known key found for this signature in database
2 changed files with 40 additions and 1 deletions

View file

@ -34,6 +34,7 @@ pub struct TimelineEventView {
pub link: String, pub link: String,
pub external_link: Option<String>, pub external_link: Option<String>,
pub details: Vec<TimelineEventDetailView>, pub details: Vec<TimelineEventDetailView>,
pub subtitle: Option<String>,
pub tasting_notes: Option<Vec<String>>, pub tasting_notes: Option<Vec<String>>,
pub brew_data: Option<TimelineBrewDataView>, pub brew_data: Option<TimelineBrewDataView>,
} }
@ -104,6 +105,8 @@ impl TimelineEventView {
None None
}; };
let subtitle = Self::build_subtitle(entity_type.as_str(), &mapped_details);
let brew_data_view = brew_data.map(|bd| TimelineBrewDataView { let brew_data_view = brew_data.map(|bd| TimelineBrewDataView {
bag_id: bd.bag_id, bag_id: bd.bag_id,
grinder_id: bd.grinder_id, grinder_id: bd.grinder_id,
@ -126,11 +129,47 @@ impl TimelineEventView {
link, link,
external_link, external_link,
details: mapped_details, details: mapped_details,
subtitle,
tasting_notes, tasting_notes,
brew_data: brew_data_view, brew_data: brew_data_view,
} }
} }
fn build_subtitle(entity_type: &str, details: &[TimelineEventDetailView]) -> Option<String> {
let find_value = |label: &str| {
details
.iter()
.find(|d| d.label.eq_ignore_ascii_case(label))
.map(|d| d.value.trim())
.filter(|v| !v.is_empty() && *v != "\u{2014}")
};
let picks: &[&str] = match entity_type {
"brew" => &["Roaster", "Brewer"],
"roast" => &["Roaster", "Origin"],
"cafe" => &["City", "Country"],
"cup" => &["Roaster", "Cafe"],
_ => &[],
};
let parts: Vec<&str> = if picks.is_empty() {
details
.iter()
.take(3)
.map(|d| d.value.trim())
.filter(|v| !v.is_empty() && *v != "\u{2014}")
.collect()
} else {
picks.iter().filter_map(|l| find_value(l)).collect()
};
if parts.is_empty() {
None
} else {
Some(parts.join(" \u{00b7} "))
}
}
fn map_details( fn map_details(
details: Vec<TimelineEventDetail>, details: Vec<TimelineEventDetail>,
) -> (Vec<TimelineEventDetailView>, Option<String>) { ) -> (Vec<TimelineEventDetailView>, Option<String>) {

View file

@ -263,7 +263,7 @@
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 shadow-sm flex items-center justify-between gap-3"> <div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 shadow-sm flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<span class="inline-flex w-28 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800">{{ event.kind_label }}</span> <span class="inline-flex w-28 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800">{{ event.kind_label }}</span>
<a href="{{ event.link }}" class="truncate font-medium text-stone-700 hover:text-amber-600 text-sm">{{ event.title }}</a> <p class="min-w-0 truncate text-sm"><a href="{{ event.link }}" class="font-medium text-stone-700 hover:text-amber-600">{{ event.title }}</a>{% if let Some(sub) = event.subtitle %} <span class="text-xs text-stone-400 italic">· {{ sub }}</span>{% endif %}</p>
</div> </div>
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.relative_date_label }}</time> <time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.relative_date_label }}</time>
</div> </div>