diff --git a/src/application/routes/brews.rs b/src/application/routes/brews.rs index 12a821b..ba7a3d5 100644 --- a/src/application/routes/brews.rs +++ b/src/application/routes/brews.rs @@ -57,50 +57,10 @@ async fn load_brew_form_data(state: &AppState) -> Result let gear_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc); - let grinders = state - .gear_repo - .list( - GearFilter::for_category(GearCategory::Grinder), - &gear_request, - None, - ) - .await - .map_err(AppError::from)?; - let grinder_options: Vec = grinders - .items - .into_iter() - .map(GearOptionView::from) - .collect(); - - let brewers = state - .gear_repo - .list( - GearFilter::for_category(GearCategory::Brewer), - &gear_request, - None, - ) - .await - .map_err(AppError::from)?; - let brewer_options: Vec = brewers - .items - .into_iter() - .map(GearOptionView::from) - .collect(); - - let filter_papers = state - .gear_repo - .list( - GearFilter::for_category(GearCategory::FilterPaper), - &gear_request, - None, - ) - .await - .map_err(AppError::from)?; - let filter_paper_options: Vec = filter_papers - .items - .into_iter() - .map(GearOptionView::from) - .collect(); + let grinder_options = load_gear_options(state, GearCategory::Grinder, &gear_request).await?; + let brewer_options = load_gear_options(state, GearCategory::Brewer, &gear_request).await?; + let filter_paper_options = + load_gear_options(state, GearCategory::FilterPaper, &gear_request).await?; let last_brew_request = ListRequest::new( 1, @@ -129,6 +89,19 @@ async fn load_brew_form_data(state: &AppState) -> Result }) } +async fn load_gear_options( + state: &AppState, + category: GearCategory, + request: &ListRequest, +) -> Result, AppError> { + let page = state + .gear_repo + .list(GearFilter::for_category(category), request, None) + .await + .map_err(AppError::from)?; + Ok(page.items.into_iter().map(GearOptionView::from).collect()) +} + #[tracing::instrument(skip(state))] async fn load_brew_page( state: &AppState, diff --git a/src/infrastructure/backup.rs b/src/infrastructure/backup.rs index d0f687a..3c302f3 100644 --- a/src/infrastructure/backup.rs +++ b/src/infrastructure/backup.rs @@ -12,9 +12,33 @@ use crate::domain::gear::{Gear, GearCategory}; use crate::domain::ids::{BagId, BrewId, CafeId, GearId, RoastId, RoasterId, TimelineEventId}; use crate::domain::roasters::Roaster; use crate::domain::roasts::Roast; -use crate::domain::timeline::{TimelineBrewData, TimelineEvent, TimelineEventDetail}; +use crate::domain::timeline::TimelineEvent; use crate::infrastructure::database::{DatabasePool, DatabaseTransaction}; +fn decode_json_vec( + raw: Option, + label: &str, +) -> anyhow::Result> { + match raw { + Some(s) if !s.is_empty() => { + from_str(&s).with_context(|| format!("failed to decode {label}: {s}")) + } + _ => Ok(Vec::new()), + } +} + +fn decode_json_opt( + raw: Option, + label: &str, +) -> anyhow::Result> { + match raw { + Some(s) if !s.is_empty() => from_str(&s) + .map(Some) + .with_context(|| format!("failed to decode {label}: {s}")), + _ => Ok(None), + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct BackupData { pub version: u32, @@ -480,11 +504,7 @@ struct RoastRecord { impl RoastRecord { fn into_domain(self) -> anyhow::Result { - let tasting_notes = match self.tasting_notes { - Some(raw) => from_str::>(&raw) - .with_context(|| format!("failed to decode tasting notes: {raw}"))?, - None => Vec::new(), - }; + let tasting_notes = decode_json_vec(self.tasting_notes, "tasting notes")?; Ok(Roast { id: RoastId::from(self.id), @@ -611,25 +631,9 @@ struct TimelineEventRecord { impl TimelineEventRecord { fn into_domain(self) -> anyhow::Result { - let details = match self.details_json { - Some(raw) if !raw.is_empty() => from_str::>(&raw) - .with_context(|| format!("failed to decode timeline event details: {raw}"))?, - _ => Vec::new(), - }; - - let tasting_notes = match self.tasting_notes_json { - Some(raw) if !raw.is_empty() => from_str::>(&raw) - .with_context(|| format!("failed to decode timeline tasting notes: {raw}"))?, - _ => Vec::new(), - }; - - let brew_data = match self.brew_data_json { - Some(raw) if !raw.is_empty() => Some( - from_str::(&raw) - .with_context(|| format!("failed to decode timeline brew data: {raw}"))?, - ), - _ => None, - }; + let details = decode_json_vec(self.details_json, "timeline event details")?; + let tasting_notes = decode_json_vec(self.tasting_notes_json, "timeline tasting notes")?; + let brew_data = decode_json_opt(self.brew_data_json, "timeline brew data")?; Ok(TimelineEvent { id: TimelineEventId::from(self.id), diff --git a/src/infrastructure/foursquare.rs b/src/infrastructure/foursquare.rs index 3d9731f..67e2e2a 100644 --- a/src/infrastructure/foursquare.rs +++ b/src/infrastructure/foursquare.rs @@ -84,46 +84,44 @@ pub async fn search_nearby( let cafes = result .results .into_iter() - .filter_map(|place| { - if place.name.is_empty() { - return None; - } - - let place_lat = place.latitude?; - let place_lng = place.longitude?; - let place_location = place.location.unwrap_or_default(); - - let country = place_location - .country - .as_deref() - .map(country_name) - .unwrap_or_default(); - - let distance = place.distance.unwrap_or_else(|| { - if let SearchLocation::Coordinates { lat, lng } = location { - haversine_distance(*lat, *lng, place_lat, place_lng) as u32 - } else { - 0 - } - }); - - let website = place.website.filter(|w| !w.trim().is_empty()); - - Some(NearbyCafe { - name: place.name, - latitude: place_lat, - longitude: place_lng, - city: place_location.locality.unwrap_or_default(), - country, - website, - distance_meters: distance, - }) - }) + .filter_map(|place| parse_cafe(place, location)) .collect(); Ok(cafes) } +fn parse_cafe(place: FoursquarePlace, location: &SearchLocation) -> Option { + if place.name.is_empty() { + return None; + } + + let lat = place.latitude?; + let lng = place.longitude?; + let loc = place.location.unwrap_or_default(); + + let country = loc.country.as_deref().map(country_name).unwrap_or_default(); + + let distance = place.distance.unwrap_or_else(|| match location { + SearchLocation::Coordinates { + lat: ref_lat, + lng: ref_lng, + } => haversine_distance(*ref_lat, *ref_lng, lat, lng) as u32, + SearchLocation::Near(_) => 0, + }); + + let website = place.website.filter(|w| !w.trim().is_empty()); + + Some(NearbyCafe { + name: place.name, + latitude: lat, + longitude: lng, + city: loc.locality.unwrap_or_default(), + country, + website, + distance_meters: distance, + }) +} + /// Converts a 2-letter ISO 3166-1 alpha-2 country code to a full country name. /// Falls back to the raw code if the lookup fails. fn country_name(code: &str) -> String { diff --git a/src/infrastructure/repositories/pagination.rs b/src/infrastructure/repositories/pagination.rs index c1751de..9d87c17 100644 --- a/src/infrastructure/repositories/pagination.rs +++ b/src/infrastructure/repositories/pagination.rs @@ -1,4 +1,4 @@ -use sqlx::{FromRow, QueryBuilder, query_as, query_scalar}; +use sqlx::{FromRow, QueryBuilder, query_scalar}; use crate::domain::RepositoryError; use crate::domain::listing::{ListRequest, Page, PageSize, SortKey}; @@ -42,22 +42,7 @@ where { match request.page_size() { PageSize::All => { - let records: Vec = if let Some(sf) = search { - let mut qb = QueryBuilder::new(base_query); - append_search_condition(&mut qb, base_query, sf); - qb.push(" ORDER BY "); - qb.push(order_clause); - qb.build_query_as() - .fetch_all(pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))? - } else { - let query = format!("{base_query} ORDER BY {order_clause}"); - query_as::<_, R>(&query) - .fetch_all(pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))? - }; + let records = fetch_records::(pool, base_query, order_clause, search, None).await?; let mut items = Vec::with_capacity(records.len()); for record in records { @@ -72,31 +57,29 @@ where let mut page = request.page(); let offset = i64::from(page - 1).saturating_mul(limit); - let total: i64 = if let Some(sf) = search { - let mut count_qb = QueryBuilder::new(count_query); - append_search_condition(&mut count_qb, count_query, sf); - let row: (i64,) = count_qb - .build_query_as() - .fetch_one(pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - row.0 - } else { - query_scalar(count_query) - .fetch_one(pool) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))? - }; + let total = fetch_count(pool, count_query, search).await?; - let mut records = - fetch_page::(pool, base_query, order_clause, search, limit, offset).await?; + let mut records = fetch_records::( + pool, + base_query, + order_clause, + search, + Some((limit, offset)), + ) + .await?; if page > 1 && records.is_empty() && total > 0 { let last_page = ((total + limit - 1) / limit) as u32; page = last_page.max(1); let offset = i64::from(page - 1).saturating_mul(limit); - records = - fetch_page::(pool, base_query, order_clause, search, limit, offset).await?; + records = fetch_records::( + pool, + base_query, + order_clause, + search, + Some((limit, offset)), + ) + .await?; } let mut items = Vec::with_capacity(records.len()); @@ -109,36 +92,51 @@ where } } -async fn fetch_page( +async fn fetch_records( pool: &DatabasePool, base_query: &str, order_clause: &str, search: Option<&SearchFilter>, - limit: i64, - offset: i64, + limit_offset: Option<(i64, i64)>, ) -> Result, RepositoryError> where R: for<'r> FromRow<'r, DatabaseRow> + Send + Unpin, { + let mut qb = QueryBuilder::new(base_query); if let Some(sf) = search { - let mut qb = QueryBuilder::new(base_query); append_search_condition(&mut qb, base_query, sf); - qb.push(" ORDER BY "); - qb.push(order_clause); + } + qb.push(" ORDER BY "); + qb.push(order_clause); + if let Some((limit, offset)) = limit_offset { qb.push(" LIMIT "); qb.push_bind(limit); qb.push(" OFFSET "); qb.push_bind(offset); - qb.build_query_as() - .fetch_all(pool) + } + qb.build_query_as() + .fetch_all(pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) +} + +async fn fetch_count( + pool: &DatabasePool, + count_query: &str, + search: Option<&SearchFilter>, +) -> Result { + if let Some(sf) = search { + let mut qb = QueryBuilder::new(count_query); + append_search_condition(&mut qb, count_query, sf); + let row: (i64,) = qb + .build_query_as() + .fetch_one(pool) .await - .map_err(|err| RepositoryError::unexpected(err.to_string())) + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + Ok(row.0) } else { - let query_sql = format!("{base_query} ORDER BY {order_clause} LIMIT ? OFFSET ?"); - query_as::<_, R>(&query_sql) - .bind(limit) - .bind(offset) - .fetch_all(pool) + query_scalar(count_query) + .fetch_one(pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string())) } diff --git a/src/presentation/web/views/timeline.rs b/src/presentation/web/views/timeline.rs index 2815060..1b76017 100644 --- a/src/presentation/web/views/timeline.rs +++ b/src/presentation/web/views/timeline.rs @@ -2,6 +2,11 @@ use crate::domain::timeline::{TimelineEvent, TimelineEventDetail}; use super::relative_date; +/// 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}" +} + #[derive(Clone)] pub struct TimelineEventDetailView { pub label: String, @@ -141,7 +146,7 @@ impl TimelineEventView { .iter() .find(|d| d.label.eq_ignore_ascii_case(label)) .map(|d| d.value.trim()) - .filter(|v| !v.is_empty() && *v != "\u{2014}") + .filter(|v| !is_blank(v)) }; let picks: &[&str] = match entity_type { @@ -157,7 +162,7 @@ impl TimelineEventView { .iter() .take(3) .map(|d| d.value.trim()) - .filter(|v| !v.is_empty() && *v != "\u{2014}") + .filter(|v| !is_blank(v)) .collect() } else { picks.iter().filter_map(|l| find_value(l)).collect() @@ -175,34 +180,41 @@ impl TimelineEventView { ) -> (Vec, Option) { let mut mapped = Vec::new(); let mut external_link = None; + for detail in details { - if detail.label.eq_ignore_ascii_case("homepage") - || detail.label.eq_ignore_ascii_case("website") - { - let trimmed = detail.value.trim(); - if !trimmed.is_empty() && trimmed != "—" { - external_link = Some(trimmed.to_string()); + 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()); + } } - } else if detail.label.eq_ignore_ascii_case("position") { - let trimmed = detail.value.trim(); - if !trimmed.is_empty() { - let display = trimmed - .strip_prefix("https://www.google.com/maps?q=") - .unwrap_or(trimmed); + // Position values become clickable map links + "position" => { + let trimmed = detail.value.trim(); + if !trimmed.is_empty() { + let display = trimmed + .strip_prefix("https://www.google.com/maps?q=") + .unwrap_or(trimmed); + mapped.push(TimelineEventDetailView { + label: detail.label, + value: display.to_string(), + link: Some(trimmed.to_string()), + }); + } + } + _ => { mapped.push(TimelineEventDetailView { label: detail.label, - value: display.to_string(), - link: Some(trimmed.to_string()), + value: detail.value, + link: None, }); } - } else { - mapped.push(TimelineEventDetailView { - label: detail.label, - value: detail.value, - link: None, - }); } } + (mapped, external_link) } }