diff --git a/src/application/routes/app/home.rs b/src/application/routes/app/home.rs index 8e0751f..a4aa858 100644 --- a/src/application/routes/app/home.rs +++ b/src/application/routes/app/home.rs @@ -7,11 +7,7 @@ use crate::application::routes::render_html; use crate::application::state::AppState; use crate::domain::bags::{BagFilter, BagSortKey}; use crate::domain::brews::{BrewFilter, BrewSortKey}; -use crate::domain::cafes::CafeSortKey; -use crate::domain::cups::CupFilter; use crate::domain::listing::{ListRequest, PageSize, SortDirection, SortKey}; -use crate::domain::roasters::RoasterSortKey; -use crate::domain::roasts::RoastSortKey; use crate::domain::timeline::TimelineSortKey; use rand::seq::SliceRandom; @@ -27,17 +23,35 @@ pub(crate) async fn home_page( ) -> Result { let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; - let (content, stats) = - tokio::try_join!(load_home_content(&state), load_stats(&state),).map_err(map_app_error)?; + let content = load_home_content(&state).await.map_err(map_app_error)?; - let stat_cards = state - .stats_repo - .get_cached() - .await - .ok() - .flatten() - .map(build_stat_cards) - .unwrap_or_default(); + let cached = state.stats_repo.get_cached().await.ok().flatten(); + + let stats = if let Some(ref cs) = cached { + StatsView { + brews: cs.entity_counts.brews, + roasts: cs.entity_counts.roasts, + roasters: cs.entity_counts.roasters, + cups: cs.entity_counts.cups, + cafes: cs.entity_counts.cafes, + bags: cs.entity_counts.bags, + } + } else { + // Fallback: compute counts directly when cache is empty (e.g. first page load) + match state.stats_repo.entity_counts().await { + Ok(ec) => StatsView { + brews: ec.brews, + roasts: ec.roasts, + roasters: ec.roasters, + cups: ec.cups, + cafes: ec.cafes, + bags: ec.bags, + }, + Err(_) => StatsView::default(), + } + }; + + let stat_cards = cached.map(build_stat_cards).unwrap_or_default(); let template = HomeTemplate { nav_active: "home", @@ -60,13 +74,6 @@ struct HomeContent { recent_events: Vec, } -/// Build a `ListRequest` that fetches page 1 with 1 item, using a sort key's -/// defaults. Used to obtain `Page.total` for entity counts. -fn count_request() -> ListRequest { - let key = K::default(); - ListRequest::new(1, PageSize::limited(1), key, key.default_direction()) -} - async fn load_home_content(state: &AppState) -> Result { let recent_brews_req = ListRequest::new( 1, @@ -132,69 +139,6 @@ async fn load_home_content(state: &AppState) -> Result { }) } -async fn load_stats(state: &AppState) -> Result { - let req_roasters: ListRequest = count_request(); - let req_roasts: ListRequest = count_request(); - let req_bags: ListRequest = count_request(); - let req_brews: ListRequest = count_request(); - let req_cafes: ListRequest = count_request(); - let req_cups: ListRequest = count_request(); - - let (roasters, roasts, bags, brews, cafes, cups) = tokio::try_join!( - async { - state - .roaster_repo - .list(&req_roasters, None) - .await - .map_err(AppError::from) - }, - async { - state - .roast_repo - .list(&req_roasts, None) - .await - .map_err(AppError::from) - }, - async { - state - .bag_repo - .list(BagFilter::all(), &req_bags, None) - .await - .map_err(AppError::from) - }, - async { - state - .brew_repo - .list(BrewFilter::all(), &req_brews, None) - .await - .map_err(AppError::from) - }, - async { - state - .cafe_repo - .list(&req_cafes, None) - .await - .map_err(AppError::from) - }, - async { - state - .cup_repo - .list(CupFilter::all(), &req_cups, None) - .await - .map_err(AppError::from) - }, - )?; - - Ok(StatsView { - brews: brews.total, - roasts: roasts.total, - roasters: roasters.total, - cups: cups.total, - cafes: cafes.total, - bags: bags.total, - }) -} - fn build_stat_cards(cs: CachedStats) -> Vec { let mut cards = vec![ StatCard { diff --git a/src/application/services/stats.rs b/src/application/services/stats.rs index 48dd4d7..0239fca 100644 --- a/src/application/services/stats.rs +++ b/src/application/services/stats.rs @@ -68,6 +68,7 @@ pub async fn compute_all_stats( roast_counts, cup_counts, cafe_counts, + entity_counts, ) = tokio::join!( repo.roast_summary(), repo.consumption_summary(), @@ -76,6 +77,7 @@ pub async fn compute_all_stats( repo.roast_origin_counts(), repo.cup_country_counts(), repo.cafe_country_counts(), + repo.entity_counts(), ); let cached = CachedStats { @@ -87,6 +89,7 @@ pub async fn compute_all_stats( geo_cups: GeoStats::from_counts(cup_counts?), geo_cafes: GeoStats::from_counts(cafe_counts?), computed_at: chrono::Utc::now().to_rfc3339(), + entity_counts: entity_counts?, }; info!(duration_ms = start.elapsed().as_millis(), "stats computed"); diff --git a/src/domain/analytics/stats.rs b/src/domain/analytics/stats.rs index 889d1f0..53b9e29 100644 --- a/src/domain/analytics/stats.rs +++ b/src/domain/analytics/stats.rs @@ -36,6 +36,17 @@ pub struct BrewingSummaryStats { pub max_brew_time_count: u64, } +/// Entity counts for the home page. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EntityCounts { + pub roasters: u64, + pub roasts: u64, + pub bags: u64, + pub brews: u64, + pub cafes: u64, + pub cups: u64, +} + /// Pre-computed snapshot of all statistics, stored as JSON in the cache table. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CachedStats { @@ -47,4 +58,6 @@ pub struct CachedStats { pub geo_cups: GeoStats, pub geo_cafes: GeoStats, pub computed_at: String, + #[serde(default)] + pub entity_counts: EntityCounts, } diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 18d331e..7c864d1 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -300,6 +300,7 @@ pub trait StatsRepository: Send + Sync { async fn brewing_summary( &self, ) -> Result; + async fn entity_counts(&self) -> Result; async fn get_cached( &self, ) -> Result, RepositoryError>; diff --git a/src/infrastructure/repositories/analytics/stats.rs b/src/infrastructure/repositories/analytics/stats.rs index e72bd91..aea5912 100644 --- a/src/infrastructure/repositories/analytics/stats.rs +++ b/src/infrastructure/repositories/analytics/stats.rs @@ -4,7 +4,9 @@ use tracing::info; use crate::domain::RepositoryError; use crate::domain::repositories::StatsRepository; -use crate::domain::stats::{BrewingSummaryStats, CachedStats, ConsumptionStats, RoastSummaryStats}; +use crate::domain::stats::{ + BrewingSummaryStats, CachedStats, ConsumptionStats, EntityCounts, RoastSummaryStats, +}; use crate::infrastructure::database::DatabasePool; #[derive(Clone)] @@ -296,6 +298,56 @@ impl StatsRepository for SqlStatsRepository { }) } + async fn entity_counts(&self) -> Result { + let (roasters, roasts, bags, brews, cafes, cups) = tokio::try_join!( + async { + query_scalar::<_, i64>(r"SELECT COUNT(*) FROM roasters") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) + }, + async { + query_scalar::<_, i64>(r"SELECT COUNT(*) FROM roasts") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) + }, + async { + query_scalar::<_, i64>(r"SELECT COUNT(*) FROM bags") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) + }, + async { + query_scalar::<_, i64>(r"SELECT COUNT(*) FROM brews") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) + }, + async { + query_scalar::<_, i64>(r"SELECT COUNT(*) FROM cafes") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) + }, + async { + query_scalar::<_, i64>(r"SELECT COUNT(*) FROM cups") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string())) + }, + )?; + + Ok(EntityCounts { + roasters: roasters as u64, + roasts: roasts as u64, + bags: bags as u64, + brews: brews as u64, + cafes: cafes as u64, + cups: cups as u64, + }) + } + async fn get_cached(&self) -> Result, RepositoryError> { let row = sqlx::query(r"SELECT data FROM stats_cache WHERE id = 1") .fetch_optional(&self.pool) diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index a2ab9f1..955e7b8 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -20,6 +20,7 @@ pub use timeline::{ TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView, }; +#[derive(Default)] pub struct StatsView { pub brews: u64, pub roasts: u64,