From dbdb15a4928aa16697cb856bb98a5f700a5df381 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Sun, 8 Feb 2026 15:21:19 +0000 Subject: [PATCH] feat(home): add stats cards and horizontal scroll to data section - Add build_stat_cards() with shuffled order from stats cache - Remove gear from homepage data cards and StatsView - Add StatCard view struct for icon/value/label display - Convert data cards from grid to horizontal scroll with chip-scroll - Add stats section with 6 randomized stat cards --- src/application/routes/app/home.rs | 70 +++++++++++++++++++++++++----- src/presentation/web/views/mod.rs | 7 ++- templates/pages/home.html | 64 ++++++++++++++++++++++----- 3 files changed, 118 insertions(+), 23 deletions(-) diff --git a/src/application/routes/app/home.rs b/src/application/routes/app/home.rs index 17477ae..3108ca8 100644 --- a/src/application/routes/app/home.rs +++ b/src/application/routes/app/home.rs @@ -9,13 +9,15 @@ 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::gear::GearFilter; 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, StatsView, TimelineEventView}; +use crate::presentation::web::views::{BagView, BrewView, StatCard, StatsView, TimelineEventView}; #[allow(clippy::similar_names)] #[tracing::instrument(skip(state, cookies))] @@ -28,6 +30,15 @@ pub(crate) async fn home_page( 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, @@ -36,6 +47,7 @@ pub(crate) async fn home_page( open_bags: content.open_bags, recent_events: content.recent_events, stats, + stat_cards, }; render_html(template).map(IntoResponse::into_response) @@ -124,11 +136,10 @@ async fn load_stats(state: &AppState) -> Result { let req_roasts: ListRequest = count_request(); let req_bags: ListRequest = count_request(); let req_brews: ListRequest = count_request(); - let req_gear: ListRequest = count_request(); let req_cafes: ListRequest = count_request(); let req_cups: ListRequest = count_request(); - let (roasters, roasts, bags, brews, gear, cafes, cups) = tokio::try_join!( + let (roasters, roasts, bags, brews, cafes, cups) = tokio::try_join!( async { state .roaster_repo @@ -157,13 +168,6 @@ async fn load_stats(state: &AppState) -> Result { .await .map_err(AppError::from) }, - async { - state - .gear_repo - .list(GearFilter::all(), &req_gear, None) - .await - .map_err(AppError::from) - }, async { state .cafe_repo @@ -187,6 +191,48 @@ async fn load_stats(state: &AppState) -> Result { cups: cups.total, cafes: cafes.total, bags: bags.total, - gear: gear.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 +} diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index 2c9de28..e536ad7 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -27,7 +27,12 @@ pub struct StatsView { pub cups: u64, pub cafes: u64, pub bags: u64, - pub gear: u64, +} + +pub struct StatCard { + pub icon: &'static str, + pub value: String, + pub label: &'static str, } use chrono::{DateTime, Utc}; diff --git a/templates/pages/home.html b/templates/pages/home.html index 21ccd98..abeaa7e 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -257,12 +257,57 @@ is_authenticated %} {% endif %} + +{% if !stat_cards.is_empty() %} +
+
+

Stats

+ View all stats → +
+ + + + + +
+{% endif %} +
- + +
{% endblock %}