diff --git a/src/application/routes/app/mod.rs b/src/application/routes/app/mod.rs index 31fceea..5b4348f 100644 --- a/src/application/routes/app/mod.rs +++ b/src/application/routes/app/mod.rs @@ -36,6 +36,7 @@ pub(super) fn router() -> axum::Router { ) .route("/components/chip-scroll.js", get(chip_scroll_js)) .route("/components/world-map.js", get(world_map_js)) + .route("/components/donut-chart.js", get(donut_chart_js)) .route("/favicon-light.svg", get(favicon_light)) .route("/favicon-dark.svg", get(favicon_dark)) } @@ -104,6 +105,16 @@ async fn world_map_js() -> impl IntoResponse { ) } +async fn donut_chart_js() -> impl IntoResponse { + ( + [ + ("content-type", "application/javascript; charset=utf-8"), + ("cache-control", "public, max-age=604800"), + ], + include_str!("../../../../static/js/components/donut-chart.js"), + ) +} + async fn favicon_light() -> impl IntoResponse { ( [ diff --git a/src/application/routes/app/stats.rs b/src/application/routes/app/stats.rs index 094b846..a2ec796 100644 --- a/src/application/routes/app/stats.rs +++ b/src/application/routes/app/stats.rs @@ -1,13 +1,17 @@ use axum::extract::{Query, State}; +use axum::http::header::HeaderValue; use axum::http::{HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; +use axum::response::{Html, IntoResponse, Response}; +use chrono::Utc; use serde::Deserialize; -use crate::application::errors::{AppError, map_app_error}; +use crate::application::errors::map_app_error; use crate::application::routes::render_html; use crate::application::routes::support::is_datastar_request; +use crate::application::services::stats::compute_all_stats; use crate::application::state::AppState; -use crate::domain::country_stats::{CountryStat, GeoStats, country_to_iso, iso_to_flag_emoji}; +use crate::domain::country_stats::GeoStats; +use crate::domain::stats::CachedStats; use crate::presentation::web::templates::{ StatsMapFragment, StatsPageTemplate, Tab, render_template, }; @@ -51,21 +55,17 @@ pub(crate) async fn stats_page( let entity_type = stats_query.entity_type; let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; - let geo_stats = load_geo_stats(&state, &entity_type) - .await - .map_err(map_app_error)?; - - let content = render_template(StatsMapFragment { - geo_stats: &geo_stats, - }) - .map_err(|err| { - tracing::error!(error = %err, "failed to render stats fragment"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - + // Datastar tab switch: only need geo stats for the selected tab if is_datastar_request(&headers) { - use axum::http::header::HeaderValue; - use axum::response::Html; + let geo_stats = geo_for_type(&load_or_compute(&state).await?, &entity_type); + + let content = render_template(StatsMapFragment { + geo_stats: &geo_stats, + }) + .map_err(|err| { + tracing::error!(error = %err, "failed to render stats fragment"); + StatusCode::INTERNAL_SERVER_ERROR + })?; let mut response = Html(content).into_response(); response.headers_mut().insert( @@ -78,6 +78,18 @@ pub(crate) async fn stats_page( return Ok(response); } + // Full page load: use cached stats or compute on the fly + let cached = load_or_compute(&state).await?; + let geo_stats = geo_for_type(&cached, &entity_type); + + let content = render_template(StatsMapFragment { + geo_stats: &geo_stats, + }) + .map_err(|err| { + tracing::error!(error = %err, "failed to render stats fragment"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let tabs: Vec = TABS .iter() .map(|t| Tab { @@ -86,6 +98,25 @@ pub(crate) async fn stats_page( }) .collect(); + let cache_age = format_cache_age(&cached.computed_at); + let consumption_30d_weight = + crate::domain::formatting::format_weight(cached.consumption.last_30_days_grams); + let consumption_all_time_weight = + crate::domain::formatting::format_weight(cached.consumption.all_time_grams); + let grinder_weights: Vec<(String, f64, String)> = cached + .brewing_summary + .grinder_weight_counts + .iter() + .map(|(name, grams)| { + ( + name.clone(), + *grams, + crate::domain::formatting::format_weight(*grams), + ) + }) + .collect(); + let max_grinder_weight = cached.brewing_summary.max_grinder_weight; + let template = StatsPageTemplate { nav_active: "stats", is_authenticated, @@ -98,43 +129,44 @@ pub(crate) async fn stats_page( tab_fetch_target: "#stats-content", tab_fetch_mode: "inner", content, + roast_summary: cached.roast_summary, + consumption: cached.consumption, + brewing_summary: cached.brewing_summary, + grinder_weights, + max_grinder_weight, + consumption_30d_weight, + consumption_all_time_weight, + cache_age, }; render_html(template).map(IntoResponse::into_response) } -async fn load_geo_stats(state: &AppState, entity_type: &str) -> Result { - let raw_counts = match entity_type { - "roasts" => state.stats_repo.roast_origin_counts().await?, - "cups" => state.stats_repo.cup_country_counts().await?, - "cafes" => state.stats_repo.cafe_country_counts().await?, - _ => state.stats_repo.roaster_country_counts().await?, - }; - - let entries: Vec = raw_counts - .into_iter() - .map(|(name, count)| { - let iso = country_to_iso(&name).unwrap_or("").to_string(); - let flag = if iso.is_empty() { - String::new() - } else { - iso_to_flag_emoji(&iso) - }; - CountryStat { - country_name: name, - iso_code: iso, - flag_emoji: flag, - count, - } - }) - .collect(); - - let total_countries = entries.len(); - let max_count = entries.iter().map(|e| e.count).max().unwrap_or(0); - - Ok(GeoStats { - entries, - total_countries, - max_count, - }) +/// Load stats from cache, falling back to live computation on cache miss. +async fn load_or_compute(state: &AppState) -> Result { + if let Ok(Some(cached)) = state.stats_repo.get_cached().await { + return Ok(cached); + } + tracing::debug!("stats cache miss, computing live"); + compute_all_stats(&*state.stats_repo) + .await + .map_err(|e| map_app_error(e.into())) +} + +/// Select the geo stats for a given entity type from the cached snapshot. +fn geo_for_type(cached: &CachedStats, entity_type: &str) -> GeoStats { + match entity_type { + "roasts" => cached.geo_roasts.clone(), + "cups" => cached.geo_cups.clone(), + "cafes" => cached.geo_cafes.clone(), + _ => cached.geo_roasters.clone(), + } +} + +/// Format the cache timestamp as a relative age string (e.g. "Just now", "2m ago"). +fn format_cache_age(computed_at: &str) -> String { + let Ok(ts) = chrono::DateTime::parse_from_rfc3339(computed_at) else { + return String::new(); + }; + crate::domain::formatting::format_relative_time(ts.with_timezone(&Utc), Utc::now()) } diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 0df727a..e062231 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -3,8 +3,8 @@ use askama::Template; use super::views::{ BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView, - RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatsView, TimelineEventView, - TimelineMonthView, + RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView, + TimelineEventView, TimelineMonthView, }; use crate::domain::bags::BagSortKey; use crate::domain::brews::BrewSortKey; @@ -13,6 +13,7 @@ use crate::domain::cups::CupSortKey; use crate::domain::gear::GearSortKey; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasts::{RoastSortKey, RoastWithRoaster}; +use crate::domain::stats::{BrewingSummaryStats, ConsumptionStats, RoastSummaryStats}; use crate::domain::timeline::TimelineSortKey; #[derive(Template)] @@ -109,6 +110,7 @@ pub struct HomeTemplate { pub open_bags: Vec, pub recent_events: Vec, pub stats: StatsView, + pub stat_cards: Vec, } #[derive(Template)] @@ -189,6 +191,14 @@ pub struct StatsPageTemplate { pub tab_fetch_target: &'static str, pub tab_fetch_mode: &'static str, pub content: String, + pub roast_summary: RoastSummaryStats, + pub consumption: ConsumptionStats, + pub brewing_summary: BrewingSummaryStats, + pub grinder_weights: Vec<(String, f64, String)>, + pub max_grinder_weight: f64, + pub consumption_30d_weight: String, + pub consumption_all_time_weight: String, + pub cache_age: String, } #[derive(Template)] diff --git a/static/js/components/donut-chart.js b/static/js/components/donut-chart.js new file mode 100644 index 0000000..e2817a8 --- /dev/null +++ b/static/js/components/donut-chart.js @@ -0,0 +1,98 @@ +const DONUT_ICONS = { + beaker: '', + grinder: '' +}; + +const esc = (s) => s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + +class DonutChart extends HTMLElement { + static get observedAttributes() { + return ['data-items']; + } + + connectedCallback() { + requestAnimationFrame(() => this.render()); + this._themeObserver = new MutationObserver(() => requestAnimationFrame(() => this.render())); + this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + } + + disconnectedCallback() { + this._themeObserver?.disconnect(); + this._themeObserver = null; + } + + attributeChangedCallback() { + if (this.isConnected) requestAnimationFrame(() => this.render()); + } + + render() { + const raw = this.dataset.items || ''; + if (!raw) { this.innerHTML = ''; return; } + + const items = raw.split('|').map(s => { + const idx = s.lastIndexOf(':'); + if (idx === -1) return null; + const label = s.slice(0, idx).trim(); + const count = parseInt(s.slice(idx + 1), 10); + return (label && count > 0) ? { label, count } : null; + }).filter(Boolean); + + if (items.length === 0) { this.innerHTML = ''; return; } + + const total = items.reduce((sum, i) => sum + i.count, 0); + const maxCount = items[0].count; + const rgb = getComputedStyle(document.documentElement).getPropertyValue('--highlight-rgb').trim() || '185, 28, 28'; + const colorFor = (count) => { + const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2); + return `rgba(${rgb}, ${alpha})`; + }; + + const size = 140; + const strokeWidth = 28; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const cx = size / 2; + const cy = size / 2; + const gapDeg = items.length > 1 ? 3 : 0; + const gapArc = (gapDeg / 360) * circumference; + + let angle = 0; + const segments = items.map((item, i) => { + const fraction = item.count / total; + const arcLen = fraction * circumference; + const visible = Math.max(0, arcLen - gapArc); + const rotation = angle - 90; + angle += fraction * 360; + return ``; + }); + + const legend = items.map((item, i) => { + const pct = Math.round(item.count / total * 100); + return `
+ + ${esc(item.label)} + ${pct}% +
`; + }); + + this.innerHTML = `
+ + ${segments.join('')} + ${(() => { + const icon = DONUT_ICONS[this.dataset.icon]; + if (!icon) return ''; + const s = 24; + return `${icon}`; + })()} + +
+ ${legend.join('')} +
+
`; + } +} + +customElements.define('donut-chart', DonutChart); diff --git a/templates/base.html b/templates/base.html index 6deb647..4d4d899 100644 --- a/templates/base.html +++ b/templates/base.html @@ -29,6 +29,7 @@ + {% block head %}{% endblock %}