use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use crate::application::errors::{AppError, map_app_error}; 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; use crate::domain::stats::CachedStats; use crate::presentation::web::templates::HomeTemplate; use crate::presentation::web::views::{BagView, BrewView, StatCard, StatsView, TimelineEventView}; #[allow(clippy::similar_names)] #[tracing::instrument(skip(state, cookies))] pub(crate) async fn home_page( State(state): State, cookies: tower_cookies::Cookies, ) -> 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 stat_cards = state .stats_repo .get_cached() .await .ok() .flatten() .map(build_stat_cards) .unwrap_or_default(); let template = HomeTemplate { nav_active: "home", is_authenticated, version_info: &crate::VERSION_INFO, base_url: crate::base_url(), recent_brews: content.recent_brews, open_bags: content.open_bags, recent_events: content.recent_events, stats, stat_cards, }; render_html(template).map(IntoResponse::into_response) } struct HomeContent { recent_brews: Vec, open_bags: Vec, 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, PageSize::limited(10), BrewSortKey::CreatedAt, SortDirection::Desc, ); let open_bags_req = ListRequest::new(1, PageSize::All, BagSortKey::UpdatedAt, SortDirection::Desc); let recent_events_req = ListRequest::new( 1, PageSize::limited(5), TimelineSortKey::default(), TimelineSortKey::default().default_direction(), ); let (recent_brews_page, open_bags_page, recent_events_page) = tokio::try_join!( async { state .brew_repo .list(BrewFilter::all(), &recent_brews_req, None) .await .map_err(AppError::from) }, async { state .bag_repo .list(BagFilter::open(), &open_bags_req, None) .await .map_err(AppError::from) }, async { state .timeline_repo .list(&recent_events_req) .await .map_err(AppError::from) }, )?; let recent_brews: Vec = recent_brews_page .items .into_iter() .map(BrewView::from_domain) .collect(); let open_bags = open_bags_page .items .into_iter() .map(BagView::from_domain) .collect(); let recent_events = recent_events_page .items .into_iter() .map(TimelineEventView::from_domain) .collect(); Ok(HomeContent { recent_brews, open_bags, recent_events, }) } 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 { icon: "coffee_bean", value: crate::domain::formatting::format_weight(cs.consumption.last_30_days_grams), label: "Coffee (30d)", }, StatCard { icon: "coffee_bean", value: crate::domain::formatting::format_weight(cs.consumption.all_time_grams), label: "All Time", }, StatCard { icon: "beaker", value: cs.consumption.brews_last_30_days.to_string(), label: "Brews (30d)", }, StatCard { icon: "map", value: cs.roast_summary.unique_origins.to_string(), label: "Origins", }, StatCard { icon: "location", value: cs .roast_summary .top_origin .unwrap_or_else(|| "\u{2014}".into()), label: "Top Origin", }, StatCard { icon: "fire", value: cs .roast_summary .top_roaster .unwrap_or_else(|| "\u{2014}".into()), label: "Top Roaster", }, ]; cards.shuffle(&mut rand::thread_rng()); cards }