brewlog/src/presentation/web/views/timeline.rs
Jon Seager 902d52b171
feat(brews): add brew time field
- Add brew_time INTEGER column to brews table (nullable, stored as seconds)
- Display as M:SS format with stepper control (+/-5s) in add form
- Replace ratio with brew time in brew list Recipe column
- Show brew time in brew cards and timeline events
- Add --brew-time CLI flag and update bootstrap script (1:45-2:45 range)
2026-02-07 11:24:07 +00:00

238 lines
7.6 KiB
Rust

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 {
value.is_empty() || value == "\u{2014}"
}
/// Rounds "lat,lon" coordinates to 3 decimal places for display.
fn round_coords(coords: &str) -> String {
let parts: Vec<&str> = coords.split(',').collect();
if parts.len() == 2
&& let (Ok(lat), Ok(lon)) = (
parts[0].trim().parse::<f64>(),
parts[1].trim().parse::<f64>(),
)
{
return format!("{lat:.3}, {lon:.3}");
}
coords.to_string()
}
#[derive(Clone)]
pub struct TimelineEventDetailView {
pub label: String,
pub value: String,
pub link: Option<String>,
}
/// Raw brew data for repeating a brew from the timeline.
#[derive(Clone)]
pub struct TimelineBrewDataView {
pub bag_id: i64,
pub grinder_id: i64,
pub brewer_id: i64,
pub filter_paper_id: Option<i64>,
pub coffee_weight: f64,
pub grind_setting: f64,
pub water_volume: i32,
pub water_temp: f64,
pub brew_time: Option<i32>,
}
#[derive(Clone)]
pub struct TimelineEventView {
pub id: String,
pub kind_label: &'static str,
pub date_label: String,
pub relative_date_label: String,
pub time_label: Option<String>,
pub iso_timestamp: String,
pub title: String,
pub link: String,
pub external_link: Option<String>,
pub details: Vec<TimelineEventDetailView>,
pub subtitle: Option<String>,
pub tasting_notes: Option<Vec<TastingNoteView>>,
pub brew_data: Option<TimelineBrewDataView>,
}
pub struct TimelineMonthView {
pub anchor: String,
pub heading: String,
pub events: Vec<TimelineEventView>,
}
impl TimelineEventView {
pub fn from_domain(event: TimelineEvent) -> Self {
let TimelineEvent {
id,
entity_type,
entity_id: _,
action,
occurred_at,
title,
details,
tasting_notes,
slug: _,
roaster_slug: _,
brew_data,
} = event;
let kind_label = match (entity_type.as_str(), action.as_str()) {
("roaster", "added") => "Roaster Added",
("roast", "added") => "Roast Added",
("bag", "added") => "Bag Added",
("bag", "finished") => "Bag Finished",
("gear", "added") => "Gear Added",
("brew", "brewed") => "Brew Added",
("cafe", "added") => "Cafe Added",
("cup", "added") => "Cup Added",
_ => "Event",
};
let link = match entity_type.as_str() {
"roaster" => "/data?type=roasters".to_string(),
"roast" | "bag" | "brew" => format!("/data?type={entity_type}s"),
"cafe" => "/data?type=cafes".to_string(),
"cup" => "/data?type=cups".to_string(),
"gear" => "/data?type=gear".to_string(),
_ => String::from("#"),
};
let (mapped_details, external_link) = Self::map_details(details);
let tasting_notes = if entity_type == "roast" {
let notes = tasting_notes
.into_iter()
.flat_map(|note| {
note.split([',', '\n'])
.map(|segment| segment.trim().to_string())
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
})
.map(|note| tasting_notes::categorize(&note))
.collect::<Vec<_>>();
Some(notes)
} else {
None
};
let subtitle = Self::build_subtitle(entity_type.as_str(), &mapped_details);
let brew_data_view = brew_data.map(|bd| TimelineBrewDataView {
bag_id: bd.bag_id,
grinder_id: bd.grinder_id,
brewer_id: bd.brewer_id,
filter_paper_id: bd.filter_paper_id,
coffee_weight: bd.coffee_weight,
grind_setting: bd.grind_setting,
water_volume: bd.water_volume,
water_temp: bd.water_temp,
brew_time: bd.brew_time,
});
Self {
id: id.to_string(),
kind_label,
date_label: occurred_at.format("%B %d, %Y").to_string(),
relative_date_label: relative_date(occurred_at),
time_label: Some(occurred_at.format("%H:%M UTC").to_string()),
iso_timestamp: occurred_at.to_rfc3339(),
title,
link,
external_link,
details: mapped_details,
subtitle,
tasting_notes,
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| !is_blank(v))
};
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| !is_blank(v))
.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(
details: Vec<TimelineEventDetail>,
) -> (Vec<TimelineEventDetailView>, Option<String>) {
let mut mapped = Vec::new();
let mut external_link = None;
for detail in details {
let label_lower = detail.label.to_ascii_lowercase();
match label_lower.as_str() {
// Website/homepage links are extracted, not shown as detail rows
"homepage" | "website" => {
let trimmed = detail.value.trim();
if !is_blank(trimmed) {
external_link = Some(trimmed.to_string());
}
}
// Position values become clickable map links
"position" => {
let trimmed = detail.value.trim();
if !trimmed.is_empty() {
let coords = trimmed
.strip_prefix("https://www.google.com/maps?q=")
.unwrap_or(trimmed);
let display = round_coords(coords);
let link = if trimmed.starts_with("http") {
trimmed.to_string()
} else {
format!("https://www.google.com/maps?q={coords}")
};
mapped.push(TimelineEventDetailView {
label: detail.label,
value: display,
link: Some(link),
});
}
}
_ => {
mapped.push(TimelineEventDetailView {
label: detail.label,
value: detail.value,
link: None,
});
}
}
}
(mapped, external_link)
}
}