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:
parent
f144526288
commit
dbdb15a492
3 changed files with 118 additions and 23 deletions
|
|
@ -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<StatsView, AppError> {
|
|||
let req_roasts: ListRequest<RoastSortKey> = count_request();
|
||||
let req_bags: ListRequest<BagSortKey> = 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_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 {
|
||||
state
|
||||
.roaster_repo
|
||||
|
|
@ -157,13 +168,6 @@ async fn load_stats(state: &AppState) -> Result<StatsView, AppError> {
|
|||
.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<StatsView, AppError> {
|
|||
cups: cups.total,
|
||||
cafes: cafes.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -257,12 +257,57 @@ is_authenticated %}
|
|||
{% endif %}
|
||||
</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 →</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 -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<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 →</a
|
||||
>
|
||||
</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
|
||||
href="/data?type=brews"
|
||||
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-sm font-medium text-accent">Cafes</span>
|
||||
</a>
|
||||
<a
|
||||
href="/data?type=gear"
|
||||
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt"
|
||||
>
|
||||
{{ icons::grinder("h-6 w-6 text-accent") }}
|
||||
<span class="text-lg font-bold text-text">{{ stats.gear }}</span>
|
||||
<span class="text-sm font-medium text-accent">Gear</span>
|
||||
</a>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue