feat: add entity counts to stats cache for home page
Add EntityCounts struct and StatsRepository::entity_counts() to query per-entity row counts. Include them in CachedStats so the home page can read counts from cache instead of issuing six list queries with LIMIT 1. Derive Default on StatsView for the fallback case.
This commit is contained in:
parent
9d25873243
commit
b8a26bbeca
6 changed files with 99 additions and 85 deletions
|
|
@ -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<Response, StatusCode> {
|
||||
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<TimelineEventView>,
|
||||
}
|
||||
|
||||
/// 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<K: SortKey>() -> ListRequest<K> {
|
||||
let key = K::default();
|
||||
ListRequest::new(1, PageSize::limited(1), key, key.default_direction())
|
||||
}
|
||||
|
||||
async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
|
||||
let recent_brews_req = ListRequest::new(
|
||||
1,
|
||||
|
|
@ -132,69 +139,6 @@ async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
|
|||
})
|
||||
}
|
||||
|
||||
async fn load_stats(state: &AppState) -> Result<StatsView, AppError> {
|
||||
let req_roasters: ListRequest<RoasterSortKey> = count_request();
|
||||
let req_roasts: ListRequest<RoastSortKey> = count_request();
|
||||
let req_bags: ListRequest<BagSortKey> = count_request();
|
||||
let req_brews: ListRequest<BrewSortKey> = count_request();
|
||||
let req_cafes: ListRequest<CafeSortKey> = count_request();
|
||||
let req_cups: ListRequest<crate::domain::cups::CupSortKey> = 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<StatCard> {
|
||||
let mut cards = vec![
|
||||
StatCard {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ pub trait StatsRepository: Send + Sync {
|
|||
async fn brewing_summary(
|
||||
&self,
|
||||
) -> Result<crate::domain::stats::BrewingSummaryStats, RepositoryError>;
|
||||
async fn entity_counts(&self) -> Result<crate::domain::stats::EntityCounts, RepositoryError>;
|
||||
async fn get_cached(
|
||||
&self,
|
||||
) -> Result<Option<crate::domain::stats::CachedStats>, RepositoryError>;
|
||||
|
|
|
|||
|
|
@ -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<EntityCounts, RepositoryError> {
|
||||
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<Option<CachedStats>, RepositoryError> {
|
||||
let row = sqlx::query(r"SELECT data FROM stats_cache WHERE id = 1")
|
||||
.fetch_optional(&self.pool)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub use timeline::{
|
|||
TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StatsView {
|
||||
pub brews: u64,
|
||||
pub roasts: u64,
|
||||
|
|
|
|||
Loading…
Reference in a new issue