feat(stats): add full stats page with consumption, brewing, and charts

- Refactor stats handler to load_or_compute with cache fallback
- Add donut charts for brewer and grinder usage (with XSS-safe labels)
- Add bar charts for origins, flavours, grind weight, and brew time
- Add consumption cards (total brewed, avg per brew, bags consumed)
- Add roast summary section (top origins, processes, producers)
- Register donut-chart.js static asset route
- Add Recompute Stats button to admin page
This commit is contained in:
Jon Seager 2026-02-08 15:20:29 +00:00
parent 97c00f2e33
commit 66c5cd0d6c
No known key found for this signature in database
8 changed files with 361 additions and 56 deletions

View file

@ -36,6 +36,7 @@ pub(super) fn router() -> axum::Router<AppState> {
)
.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 {
(
[

View file

@ -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<Tab> = 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<GeoStats, AppError> {
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<CountryStat> = 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<CachedStats, StatusCode> {
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())
}

View file

@ -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<BagView>,
pub recent_events: Vec<TimelineEventView>,
pub stats: StatsView,
pub stat_cards: Vec<StatCard>,
}
#[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)]

View file

@ -0,0 +1,98 @@
const DONUT_ICONS = {
beaker: '<path fill-rule="evenodd" d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z" clip-rule="evenodd" />',
grinder: '<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" /><rect x="5" y="16.5" width="10" height="1.5" rx=".5" />'
};
const esc = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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 `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="none"
stroke="${colorFor(item.count)}" stroke-width="${strokeWidth}"
stroke-dasharray="${visible} ${circumference - visible}"
transform="rotate(${rotation} ${cx} ${cy})" />`;
});
const legend = items.map((item, i) => {
const pct = Math.round(item.count / total * 100);
return `<div style="display:flex;align-items:center;gap:0.5rem">
<span style="flex-shrink:0;width:0.5rem;height:0.5rem;border-radius:9999px;background:${colorFor(item.count)}"></span>
<span class="text-xs text-text-secondary" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(item.label)}</span>
<span class="text-xs text-text-muted" style="margin-left:auto;flex-shrink:0">${pct}%</span>
</div>`;
});
this.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;gap:1rem">
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="display:block">
${segments.join('')}
${(() => {
const icon = DONUT_ICONS[this.dataset.icon];
if (!icon) return '';
const s = 24;
return `<svg x="${cx - s/2}" y="${cy - s/2}" width="${s}" height="${s}" viewBox="0 0 20 20" class="text-text-muted" fill="currentColor">${icon}</svg>`;
})()}
</svg>
<div style="display:flex;flex-direction:column;gap:0.375rem;width:100%">
${legend.join('')}
</div>
</div>`;
}
}
customElements.define('donut-chart', DonutChart);

View file

@ -29,6 +29,7 @@
<script defer src="/components/searchable-select.js"></script>
<script defer src="/components/chip-scroll.js"></script>
<script defer src="/components/world-map.js"></script>
<script defer src="/components/donut-chart.js"></script>
{% block head %}{% endblock %}
<script>
const toggleRow = (event) => {

View file

@ -248,6 +248,20 @@
{{ icons::arrow_up_tray("h-4 w-4") }}
Restore
</button>
<button
type="button"
data-signals:_recomputing="false"
data-on:click="$_recomputing = true; @post('/api/v1/stats/recompute')"
data-on:datastar-fetch="if (!$_recomputing) return;
if (evt.detail.type === 'finished') { $_recomputing = false; document.getElementById('backup-status').textContent = 'Stats recomputed successfully.'; document.getElementById('backup-status').classList.remove('hidden') }
else if (evt.detail.type === 'error') { $_recomputing = false; document.getElementById('backup-error').textContent = 'Stats recompute failed.'; document.getElementById('backup-error').classList.remove('hidden') }"
data-attr:disabled="$_recomputing"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
>
<span data-show="!$_recomputing">{{ icons::refresh("h-4 w-4") }}</span>
<span data-show="$_recomputing" style="display:none">{{ icons::spinner("h-4 w-4") }}</span>
Recompute Stats
</button>
<button
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt sm:w-auto sm:min-w-44"

View file

@ -1,8 +1,13 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Stats{% endblock %}
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/histogram.html" as histogram %} {% block title %}Brewlog · Stats{% endblock %}
{% block content %}
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Stats</h1>
<p class="max-w-2xl text-sm text-text-secondary">Geographic overview of coffee data.</p>
<div class="flex items-start justify-between gap-4">
<h1 class="text-3xl font-semibold">Stats</h1>
{% if !cache_age.is_empty() %}
<span class="shrink-0 text-xs text-text-muted pt-2">Updated {{ cache_age }}</span>
{% endif %}
</div>
<p class="max-w-2xl text-sm text-text-secondary">Aggregated coffee data across origins, consumption, and brewing.</p>
</header>
<div class="flex flex-col gap-6">
@ -12,4 +17,107 @@
{{ content|safe }}
</div>
</div>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Roast Stats</h2>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div class="flex flex-col items-center gap-1 rounded-lg border p-4">
{{ icons::map("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ roast_summary.unique_origins }}</span>
<span class="text-sm font-medium text-accent">Origins</span>
</div>
<div class="flex flex-col items-center gap-1 rounded-lg border p-4">
{{ icons::location("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text truncate max-w-full">{% match roast_summary.top_origin %}{% when Some with (v) %}{{ v }}{% when None %}&mdash;{% endmatch %}</span>
<span class="text-sm font-medium text-accent">Top Origin</span>
</div>
<div class="flex flex-col items-center gap-1 rounded-lg border p-4">
{{ icons::fire("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text truncate max-w-full">{% match roast_summary.top_roaster %}{% when Some with (v) %}{{ v }}{% when None %}&mdash;{% endmatch %}</span>
<span class="text-sm font-medium text-accent">Top Roaster</span>
</div>
</div>
{% if !roast_summary.origin_counts.is_empty() || !roast_summary.flavour_counts.is_empty() %}
<div class="mt-5 grid gap-5 md:grid-cols-2">
{% if !roast_summary.origin_counts.is_empty() %}
<div>
<h3 class="text-sm font-semibold text-text mb-3">Top 5 Origins</h3>
{{ histogram::bar_chart(roast_summary.origin_counts, roast_summary.max_origin_count) }}
</div>
{% endif %}
{% if !roast_summary.flavour_counts.is_empty() %}
<div>
<h3 class="text-sm font-semibold text-text mb-3">Top 5 Flavours</h3>
{{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
</div>
{% endif %}
</div>
{% endif %}
</section>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Consumption</h2>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="flex flex-col items-center gap-1 rounded-lg border p-4">
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ consumption_30d_weight }}</span>
<span class="text-sm font-medium text-accent">Last 30 Days</span>
</div>
<div class="flex flex-col items-center gap-1 rounded-lg border p-4">
{{ icons::coffee_bean("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ consumption_all_time_weight }}</span>
<span class="text-sm font-medium text-accent">All Time</span>
</div>
<div class="flex flex-col items-center gap-1 rounded-lg border p-4">
{{ icons::beaker("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ consumption.brews_last_30_days }}</span>
<span class="text-sm font-medium text-accent">Brews (30d)</span>
</div>
<div class="flex flex-col items-center gap-1 rounded-lg border p-4">
{{ icons::beaker("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ consumption.brews_all_time }}</span>
<span class="text-sm font-medium text-accent">Brews (All Time)</span>
</div>
</div>
</section>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Brewing</h2>
</div>
{% if !brewing_summary.brewer_counts.is_empty() || !brewing_summary.grinder_counts.is_empty() %}
<div class="grid gap-5 md:grid-cols-2">
{% if !brewing_summary.brewer_counts.is_empty() %}
<div class="rounded-lg border bg-surface p-5">
<h3 class="text-sm font-semibold text-text mb-4">Brewer Usage</h3>
<donut-chart data-icon="beaker" data-items="{% for item in brewing_summary.brewer_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"></donut-chart>
</div>
{% endif %}
{% if !brewing_summary.grinder_counts.is_empty() %}
<div class="rounded-lg border bg-surface p-5">
<h3 class="text-sm font-semibold text-text mb-4">Grinder Usage</h3>
<donut-chart data-icon="grinder" data-items="{% for item in brewing_summary.grinder_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"></donut-chart>
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-5 grid gap-5 md:grid-cols-2">
{% if !grinder_weights.is_empty() %}
<div>
<h3 class="text-sm font-semibold text-text mb-3">Coffee per Grinder</h3>
{{ histogram::bar_chart_weight(grinder_weights, max_grinder_weight) }}
</div>
{% endif %}
{% if !brewing_summary.brew_time_distribution.is_empty() %}
<div>
<h3 class="text-sm font-semibold text-text mb-3">Brew Time Distribution</h3>
{{ histogram::bar_chart(brewing_summary.brew_time_distribution, brewing_summary.max_brew_time_count) }}
</div>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% macro bar_chart(items, max_count) %}
<div class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-2">
{% for item in items %}
<div class="flex items-center gap-3">
<span class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate">{{ item.0 }}</span>
<div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden">
<div class="h-full rounded bg-accent" style="width: {{ item.1 * 100 / max_count }}%"></div>
</div>
<span class="w-6 shrink-0 text-xs text-text-muted text-right">{{ item.1 }}</span>
</div>
{% endfor %}
</div>
</div>
{% endmacro %}
{% macro bar_chart_weight(items, max_weight) %}
<div class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-2">
{% for item in items %}
<div class="flex items-center gap-3">
<span class="w-28 shrink-0 text-right text-xs font-medium text-text-secondary truncate">{{ item.0 }}</span>
<div class="flex-1 h-5 rounded bg-surface-alt overflow-hidden">
<div class="h-full rounded bg-accent" style="width: {{ item.1 * 100.0 / max_weight }}%"></div>
</div>
<span class="w-14 shrink-0 text-xs text-text-muted text-right">{{ item.2 }}</span>
</div>
{% endfor %}
</div>
</div>
{% endmacro %}