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::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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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 →</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 →</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 %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue