refactor: simplify overly nested and duplicated code across five modules
- Extract 35-line filter_map closure into parse_cafe() in foursquare.rs - Unify triple-duplicated search branching in pagination.rs into fetch_records() and fetch_count() helpers - DRY three identical gear-loading blocks in brews.rs via load_gear_options() helper - Flatten nested if/else-if in timeline map_details() using match, extract shared is_blank() helper - Deduplicate JSON decode pattern in backup.rs with decode_json_vec() and decode_json_opt() helpers
This commit is contained in:
parent
939743a224
commit
47c89bd725
5 changed files with 161 additions and 176 deletions
|
|
@ -57,50 +57,10 @@ async fn load_brew_form_data(state: &AppState) -> Result<BrewFormData, AppError>
|
|||
|
||||
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<GearOptionView> = 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<GearOptionView> = 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<GearOptionView> = 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<BrewFormData, AppError>
|
|||
})
|
||||
}
|
||||
|
||||
async fn load_gear_options(
|
||||
state: &AppState,
|
||||
category: GearCategory,
|
||||
request: &ListRequest<GearSortKey>,
|
||||
) -> Result<Vec<GearOptionView>, 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,
|
||||
|
|
|
|||
|
|
@ -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<T: serde::de::DeserializeOwned>(
|
||||
raw: Option<String>,
|
||||
label: &str,
|
||||
) -> anyhow::Result<Vec<T>> {
|
||||
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<T: serde::de::DeserializeOwned>(
|
||||
raw: Option<String>,
|
||||
label: &str,
|
||||
) -> anyhow::Result<Option<T>> {
|
||||
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<Roast> {
|
||||
let tasting_notes = match self.tasting_notes {
|
||||
Some(raw) => from_str::<Vec<String>>(&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<TimelineEvent> {
|
||||
let details = match self.details_json {
|
||||
Some(raw) if !raw.is_empty() => from_str::<Vec<TimelineEventDetail>>(&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::<Vec<String>>(&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::<TimelineBrewData>(&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),
|
||||
|
|
|
|||
|
|
@ -84,44 +84,42 @@ pub async fn search_nearby(
|
|||
let cafes = result
|
||||
.results
|
||||
.into_iter()
|
||||
.filter_map(|place| {
|
||||
.filter_map(|place| parse_cafe(place, location))
|
||||
.collect();
|
||||
|
||||
Ok(cafes)
|
||||
}
|
||||
|
||||
fn parse_cafe(place: FoursquarePlace, location: &SearchLocation) -> Option<NearbyCafe> {
|
||||
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 lat = place.latitude?;
|
||||
let lng = place.longitude?;
|
||||
let loc = place.location.unwrap_or_default();
|
||||
|
||||
let country = place_location
|
||||
.country
|
||||
.as_deref()
|
||||
.map(country_name)
|
||||
.unwrap_or_default();
|
||||
let country = loc.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 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: place_lat,
|
||||
longitude: place_lng,
|
||||
city: place_location.locality.unwrap_or_default(),
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
city: loc.locality.unwrap_or_default(),
|
||||
country,
|
||||
website,
|
||||
distance_meters: distance,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(cafes)
|
||||
}
|
||||
|
||||
/// Converts a 2-letter ISO 3166-1 alpha-2 country code to a full country name.
|
||||
|
|
|
|||
|
|
@ -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<R> = 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::<R>(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::<R>(pool, base_query, order_clause, search, limit, offset).await?;
|
||||
let mut records = fetch_records::<R>(
|
||||
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::<R>(pool, base_query, order_clause, search, limit, offset).await?;
|
||||
records = fetch_records::<R>(
|
||||
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<R>(
|
||||
async fn fetch_records<R>(
|
||||
pool: &DatabasePool,
|
||||
base_query: &str,
|
||||
order_clause: &str,
|
||||
search: Option<&SearchFilter>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
limit_offset: Option<(i64, i64)>,
|
||||
) -> Result<Vec<R>, RepositoryError>
|
||||
where
|
||||
R: for<'r> FromRow<'r, DatabaseRow> + Send + Unpin,
|
||||
{
|
||||
if let Some(sf) = search {
|
||||
let mut qb = QueryBuilder::new(base_query);
|
||||
if let Some(sf) = search {
|
||||
append_search_condition(&mut qb, base_query, sf);
|
||||
}
|
||||
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)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_count(
|
||||
pool: &DatabasePool,
|
||||
count_query: &str,
|
||||
search: Option<&SearchFilter>,
|
||||
) -> Result<i64, RepositoryError> {
|
||||
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()))?;
|
||||
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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 +180,19 @@ impl TimelineEventView {
|
|||
) -> (Vec<TimelineEventDetailView>, Option<String>) {
|
||||
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 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 !trimmed.is_empty() && trimmed != "—" {
|
||||
if !is_blank(trimmed) {
|
||||
external_link = Some(trimmed.to_string());
|
||||
}
|
||||
} else if detail.label.eq_ignore_ascii_case("position") {
|
||||
}
|
||||
// Position values become clickable map links
|
||||
"position" => {
|
||||
let trimmed = detail.value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let display = trimmed
|
||||
|
|
@ -195,7 +204,8 @@ impl TimelineEventView {
|
|||
link: Some(trimmed.to_string()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
}
|
||||
_ => {
|
||||
mapped.push(TimelineEventDetailView {
|
||||
label: detail.label,
|
||||
value: detail.value,
|
||||
|
|
@ -203,6 +213,8 @@ impl TimelineEventView {
|
|||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(mapped, external_link)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue