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:
Jon Seager 2026-02-13 13:06:04 +00:00
parent 9d25873243
commit b8a26bbeca
No known key found for this signature in database
6 changed files with 99 additions and 85 deletions

View file

@ -7,11 +7,7 @@ use crate::application::routes::render_html;
use crate::application::state::AppState; use crate::application::state::AppState;
use crate::domain::bags::{BagFilter, BagSortKey}; use crate::domain::bags::{BagFilter, BagSortKey};
use crate::domain::brews::{BrewFilter, BrewSortKey}; 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::listing::{ListRequest, PageSize, SortDirection, SortKey};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::RoastSortKey;
use crate::domain::timeline::TimelineSortKey; use crate::domain::timeline::TimelineSortKey;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
@ -27,17 +23,35 @@ pub(crate) async fn home_page(
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
let (content, stats) = let content = load_home_content(&state).await.map_err(map_app_error)?;
tokio::try_join!(load_home_content(&state), load_stats(&state),).map_err(map_app_error)?;
let stat_cards = state let cached = state.stats_repo.get_cached().await.ok().flatten();
.stats_repo
.get_cached() let stats = if let Some(ref cs) = cached {
.await StatsView {
.ok() brews: cs.entity_counts.brews,
.flatten() roasts: cs.entity_counts.roasts,
.map(build_stat_cards) roasters: cs.entity_counts.roasters,
.unwrap_or_default(); 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 { let template = HomeTemplate {
nav_active: "home", nav_active: "home",
@ -60,13 +74,6 @@ struct HomeContent {
recent_events: Vec<TimelineEventView>, 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> { async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
let recent_brews_req = ListRequest::new( let recent_brews_req = ListRequest::new(
1, 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> { fn build_stat_cards(cs: CachedStats) -> Vec<StatCard> {
let mut cards = vec![ let mut cards = vec![
StatCard { StatCard {

View file

@ -68,6 +68,7 @@ pub async fn compute_all_stats(
roast_counts, roast_counts,
cup_counts, cup_counts,
cafe_counts, cafe_counts,
entity_counts,
) = tokio::join!( ) = tokio::join!(
repo.roast_summary(), repo.roast_summary(),
repo.consumption_summary(), repo.consumption_summary(),
@ -76,6 +77,7 @@ pub async fn compute_all_stats(
repo.roast_origin_counts(), repo.roast_origin_counts(),
repo.cup_country_counts(), repo.cup_country_counts(),
repo.cafe_country_counts(), repo.cafe_country_counts(),
repo.entity_counts(),
); );
let cached = CachedStats { let cached = CachedStats {
@ -87,6 +89,7 @@ pub async fn compute_all_stats(
geo_cups: GeoStats::from_counts(cup_counts?), geo_cups: GeoStats::from_counts(cup_counts?),
geo_cafes: GeoStats::from_counts(cafe_counts?), geo_cafes: GeoStats::from_counts(cafe_counts?),
computed_at: chrono::Utc::now().to_rfc3339(), computed_at: chrono::Utc::now().to_rfc3339(),
entity_counts: entity_counts?,
}; };
info!(duration_ms = start.elapsed().as_millis(), "stats computed"); info!(duration_ms = start.elapsed().as_millis(), "stats computed");

View file

@ -36,6 +36,17 @@ pub struct BrewingSummaryStats {
pub max_brew_time_count: u64, 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. /// Pre-computed snapshot of all statistics, stored as JSON in the cache table.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedStats { pub struct CachedStats {
@ -47,4 +58,6 @@ pub struct CachedStats {
pub geo_cups: GeoStats, pub geo_cups: GeoStats,
pub geo_cafes: GeoStats, pub geo_cafes: GeoStats,
pub computed_at: String, pub computed_at: String,
#[serde(default)]
pub entity_counts: EntityCounts,
} }

View file

@ -300,6 +300,7 @@ pub trait StatsRepository: Send + Sync {
async fn brewing_summary( async fn brewing_summary(
&self, &self,
) -> Result<crate::domain::stats::BrewingSummaryStats, RepositoryError>; ) -> Result<crate::domain::stats::BrewingSummaryStats, RepositoryError>;
async fn entity_counts(&self) -> Result<crate::domain::stats::EntityCounts, RepositoryError>;
async fn get_cached( async fn get_cached(
&self, &self,
) -> Result<Option<crate::domain::stats::CachedStats>, RepositoryError>; ) -> Result<Option<crate::domain::stats::CachedStats>, RepositoryError>;

View file

@ -4,7 +4,9 @@ use tracing::info;
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
use crate::domain::repositories::StatsRepository; 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; use crate::infrastructure::database::DatabasePool;
#[derive(Clone)] #[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> { async fn get_cached(&self) -> Result<Option<CachedStats>, RepositoryError> {
let row = sqlx::query(r"SELECT data FROM stats_cache WHERE id = 1") let row = sqlx::query(r"SELECT data FROM stats_cache WHERE id = 1")
.fetch_optional(&self.pool) .fetch_optional(&self.pool)

View file

@ -20,6 +20,7 @@ pub use timeline::{
TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView, TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView,
}; };
#[derive(Default)]
pub struct StatsView { pub struct StatsView {
pub brews: u64, pub brews: u64,
pub roasts: u64, pub roasts: u64,