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
This commit is contained in:
Jon Seager 2026-02-08 15:21:19 +00:00
parent f144526288
commit dbdb15a492
No known key found for this signature in database
3 changed files with 118 additions and 23 deletions

View file

@ -9,13 +9,15 @@ 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::cafes::CafeSortKey;
use crate::domain::cups::CupFilter; use crate::domain::cups::CupFilter;
use crate::domain::gear::GearFilter;
use crate::domain::listing::{ListRequest, PageSize, SortDirection, SortKey}; use crate::domain::listing::{ListRequest, PageSize, SortDirection, SortKey};
use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::RoastSortKey;
use crate::domain::timeline::TimelineSortKey; use crate::domain::timeline::TimelineSortKey;
use rand::seq::SliceRandom;
use crate::domain::stats::CachedStats;
use crate::presentation::web::templates::HomeTemplate; 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)] #[allow(clippy::similar_names)]
#[tracing::instrument(skip(state, cookies))] #[tracing::instrument(skip(state, cookies))]
@ -28,6 +30,15 @@ pub(crate) async fn home_page(
let (content, stats) = let (content, stats) =
tokio::try_join!(load_home_content(&state), load_stats(&state),).map_err(map_app_error)?; 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 { let template = HomeTemplate {
nav_active: "home", nav_active: "home",
is_authenticated, is_authenticated,
@ -36,6 +47,7 @@ pub(crate) async fn home_page(
open_bags: content.open_bags, open_bags: content.open_bags,
recent_events: content.recent_events, recent_events: content.recent_events,
stats, stats,
stat_cards,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)
@ -124,11 +136,10 @@ async fn load_stats(state: &AppState) -> Result<StatsView, AppError> {
let req_roasts: ListRequest<RoastSortKey> = count_request(); let req_roasts: ListRequest<RoastSortKey> = count_request();
let req_bags: ListRequest<BagSortKey> = count_request(); let req_bags: ListRequest<BagSortKey> = count_request();
let req_brews: ListRequest<BrewSortKey> = count_request(); let req_brews: ListRequest<BrewSortKey> = count_request();
let req_gear: ListRequest<crate::domain::gear::GearSortKey> = count_request();
let req_cafes: ListRequest<CafeSortKey> = count_request(); let req_cafes: ListRequest<CafeSortKey> = count_request();
let req_cups: ListRequest<crate::domain::cups::CupSortKey> = count_request(); let req_cups: ListRequest<crate::domain::cups::CupSortKey> = count_request();
let (roasters, roasts, bags, brews, gear, cafes, cups) = tokio::try_join!( let (roasters, roasts, bags, brews, cafes, cups) = tokio::try_join!(
async { async {
state state
.roaster_repo .roaster_repo
@ -157,13 +168,6 @@ async fn load_stats(state: &AppState) -> Result<StatsView, AppError> {
.await .await
.map_err(AppError::from) .map_err(AppError::from)
}, },
async {
state
.gear_repo
.list(GearFilter::all(), &req_gear, None)
.await
.map_err(AppError::from)
},
async { async {
state state
.cafe_repo .cafe_repo
@ -187,6 +191,48 @@ async fn load_stats(state: &AppState) -> Result<StatsView, AppError> {
cups: cups.total, cups: cups.total,
cafes: cafes.total, cafes: cafes.total,
bags: bags.total, bags: bags.total,
gear: gear.total,
}) })
} }
fn build_stat_cards(cs: CachedStats) -> Vec<StatCard> {
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
}

View file

@ -27,7 +27,12 @@ pub struct StatsView {
pub cups: u64, pub cups: u64,
pub cafes: u64, pub cafes: u64,
pub bags: 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}; use chrono::{DateTime, Utc};

View file

@ -257,12 +257,57 @@ is_authenticated %}
{% endif %} {% endif %}
</section> </section>
<!-- Stats -->
{% if !stat_cards.is_empty() %}
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Stats</h2>
<a href="/stats" class="text-sm text-accent hover:text-accent-hover font-medium"
>View all stats &rarr;</a
>
</div>
<chip-scroll class="relative block">
<button type="button" aria-label="Scroll left"
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-left
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})">
{{ icons::chevron_left("h-5 w-5") }}
</button>
<div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll>
{% for card in stat_cards %}
<a href="/stats" class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt">
{% if card.icon == "coffee_bean" %}{{ icons::coffee_bean("h-6 w-6 text-accent") }}{% else if card.icon == "beaker" %}{{ icons::beaker("h-6 w-6 text-accent") }}{% else if card.icon == "map" %}{{ icons::map("h-6 w-6 text-accent") }}{% else if card.icon == "location" %}{{ icons::location("h-6 w-6 text-accent") }}{% else if card.icon == "fire" %}{{ icons::fire("h-6 w-6 text-accent") }}{% endif %}
<span class="text-lg font-bold text-text whitespace-nowrap">{{ card.value }}</span>
<span class="text-sm font-medium text-accent">{{ card.label }}</span>
</a>
{% endfor %}
</div>
<button type="button" aria-label="Scroll right"
class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-right
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})">
{{ icons::chevron_right("h-5 w-5") }}
</button>
</chip-scroll>
</section>
{% endif %}
<!-- Data Cards --> <!-- Data Cards -->
<section> <section>
<div class="flex items-center justify-between mb-5"> <div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Data</h2> <h2 class="text-lg font-semibold text-text">Data</h2>
<a href="/data" class="text-sm text-accent hover:text-accent-hover font-medium"
>View all data &rarr;</a
>
</div> </div>
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-7"> <chip-scroll class="relative block">
<button type="button" aria-label="Scroll left"
class="hidden absolute -left-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
data-scroll-left
onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: -200, behavior: 'smooth'})">
{{ icons::chevron_left("h-5 w-5") }}
</button>
<div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll>
<a <a
href="/data?type=brews" href="/data?type=brews"
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt" class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt"
@ -311,14 +356,13 @@ is_authenticated %}
<span class="text-lg font-bold text-text">{{ stats.cafes }}</span> <span class="text-lg font-bold text-text">{{ stats.cafes }}</span>
<span class="text-sm font-medium text-accent">Cafes</span> <span class="text-sm font-medium text-accent">Cafes</span>
</a> </a>
<a </div>
href="/data?type=gear" <button type="button" aria-label="Scroll right"
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt" class="hidden absolute -right-4 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1.5 text-text-muted shadow-sm transition hover:text-text"
> data-scroll-right
{{ icons::grinder("h-6 w-6 text-accent") }} onclick="this.closest('chip-scroll').querySelector('[data-chip-scroll]').scrollBy({left: 200, behavior: 'smooth'})">
<span class="text-lg font-bold text-text">{{ stats.gear }}</span> {{ icons::chevron_right("h-5 w-5") }}
<span class="text-sm font-medium text-accent">Gear</span> </button>
</a> </chip-scroll>
</div>
</section> </section>
{% endblock %} {% endblock %}