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:
parent
97c00f2e33
commit
66c5cd0d6c
8 changed files with 361 additions and 56 deletions
|
|
@ -36,6 +36,7 @@ pub(super) fn router() -> axum::Router<AppState> {
|
||||||
)
|
)
|
||||||
.route("/components/chip-scroll.js", get(chip_scroll_js))
|
.route("/components/chip-scroll.js", get(chip_scroll_js))
|
||||||
.route("/components/world-map.js", get(world_map_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-light.svg", get(favicon_light))
|
||||||
.route("/favicon-dark.svg", get(favicon_dark))
|
.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 {
|
async fn favicon_light() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::header::HeaderValue;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use chrono::Utc;
|
||||||
use serde::Deserialize;
|
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::render_html;
|
||||||
use crate::application::routes::support::is_datastar_request;
|
use crate::application::routes::support::is_datastar_request;
|
||||||
|
use crate::application::services::stats::compute_all_stats;
|
||||||
use crate::application::state::AppState;
|
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::{
|
use crate::presentation::web::templates::{
|
||||||
StatsMapFragment, StatsPageTemplate, Tab, render_template,
|
StatsMapFragment, StatsPageTemplate, Tab, render_template,
|
||||||
};
|
};
|
||||||
|
|
@ -51,9 +55,9 @@ pub(crate) async fn stats_page(
|
||||||
let entity_type = stats_query.entity_type;
|
let entity_type = stats_query.entity_type;
|
||||||
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
|
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
|
||||||
|
|
||||||
let geo_stats = load_geo_stats(&state, &entity_type)
|
// Datastar tab switch: only need geo stats for the selected tab
|
||||||
.await
|
if is_datastar_request(&headers) {
|
||||||
.map_err(map_app_error)?;
|
let geo_stats = geo_for_type(&load_or_compute(&state).await?, &entity_type);
|
||||||
|
|
||||||
let content = render_template(StatsMapFragment {
|
let content = render_template(StatsMapFragment {
|
||||||
geo_stats: &geo_stats,
|
geo_stats: &geo_stats,
|
||||||
|
|
@ -63,10 +67,6 @@ pub(crate) async fn stats_page(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
|
||||||
use axum::http::header::HeaderValue;
|
|
||||||
use axum::response::Html;
|
|
||||||
|
|
||||||
let mut response = Html(content).into_response();
|
let mut response = Html(content).into_response();
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
"datastar-selector",
|
"datastar-selector",
|
||||||
|
|
@ -78,6 +78,18 @@ pub(crate) async fn stats_page(
|
||||||
return Ok(response);
|
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
|
let tabs: Vec<Tab> = TABS
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| Tab {
|
.map(|t| Tab {
|
||||||
|
|
@ -86,6 +98,25 @@ pub(crate) async fn stats_page(
|
||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
let template = StatsPageTemplate {
|
||||||
nav_active: "stats",
|
nav_active: "stats",
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
|
|
@ -98,43 +129,44 @@ pub(crate) async fn stats_page(
|
||||||
tab_fetch_target: "#stats-content",
|
tab_fetch_target: "#stats-content",
|
||||||
tab_fetch_mode: "inner",
|
tab_fetch_mode: "inner",
|
||||||
content,
|
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)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_geo_stats(state: &AppState, entity_type: &str) -> Result<GeoStats, AppError> {
|
/// Load stats from cache, falling back to live computation on cache miss.
|
||||||
let raw_counts = match entity_type {
|
async fn load_or_compute(state: &AppState) -> Result<CachedStats, StatusCode> {
|
||||||
"roasts" => state.stats_repo.roast_origin_counts().await?,
|
if let Ok(Some(cached)) = state.stats_repo.get_cached().await {
|
||||||
"cups" => state.stats_repo.cup_country_counts().await?,
|
return Ok(cached);
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
})
|
tracing::debug!("stats cache miss, computing live");
|
||||||
.collect();
|
compute_all_stats(&*state.stats_repo)
|
||||||
|
.await
|
||||||
let total_countries = entries.len();
|
.map_err(|e| map_app_error(e.into()))
|
||||||
let max_count = entries.iter().map(|e| e.count).max().unwrap_or(0);
|
}
|
||||||
|
|
||||||
Ok(GeoStats {
|
/// Select the geo stats for a given entity type from the cached snapshot.
|
||||||
entries,
|
fn geo_for_type(cached: &CachedStats, entity_type: &str) -> GeoStats {
|
||||||
total_countries,
|
match entity_type {
|
||||||
max_count,
|
"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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ use askama::Template;
|
||||||
use super::views::{
|
use super::views::{
|
||||||
BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView,
|
BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView,
|
||||||
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView,
|
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView,
|
||||||
RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatsView, TimelineEventView,
|
RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView,
|
||||||
TimelineMonthView,
|
TimelineEventView, TimelineMonthView,
|
||||||
};
|
};
|
||||||
use crate::domain::bags::BagSortKey;
|
use crate::domain::bags::BagSortKey;
|
||||||
use crate::domain::brews::BrewSortKey;
|
use crate::domain::brews::BrewSortKey;
|
||||||
|
|
@ -13,6 +13,7 @@ use crate::domain::cups::CupSortKey;
|
||||||
use crate::domain::gear::GearSortKey;
|
use crate::domain::gear::GearSortKey;
|
||||||
use crate::domain::roasters::RoasterSortKey;
|
use crate::domain::roasters::RoasterSortKey;
|
||||||
use crate::domain::roasts::{RoastSortKey, RoastWithRoaster};
|
use crate::domain::roasts::{RoastSortKey, RoastWithRoaster};
|
||||||
|
use crate::domain::stats::{BrewingSummaryStats, ConsumptionStats, RoastSummaryStats};
|
||||||
use crate::domain::timeline::TimelineSortKey;
|
use crate::domain::timeline::TimelineSortKey;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -109,6 +110,7 @@ pub struct HomeTemplate {
|
||||||
pub open_bags: Vec<BagView>,
|
pub open_bags: Vec<BagView>,
|
||||||
pub recent_events: Vec<TimelineEventView>,
|
pub recent_events: Vec<TimelineEventView>,
|
||||||
pub stats: StatsView,
|
pub stats: StatsView,
|
||||||
|
pub stat_cards: Vec<StatCard>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -189,6 +191,14 @@ pub struct StatsPageTemplate {
|
||||||
pub tab_fetch_target: &'static str,
|
pub tab_fetch_target: &'static str,
|
||||||
pub tab_fetch_mode: &'static str,
|
pub tab_fetch_mode: &'static str,
|
||||||
pub content: String,
|
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)]
|
#[derive(Template)]
|
||||||
|
|
|
||||||
98
static/js/components/donut-chart.js
Normal file
98
static/js/components/donut-chart.js
Normal 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,'&').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 `<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);
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
<script defer src="/components/searchable-select.js"></script>
|
<script defer src="/components/searchable-select.js"></script>
|
||||||
<script defer src="/components/chip-scroll.js"></script>
|
<script defer src="/components/chip-scroll.js"></script>
|
||||||
<script defer src="/components/world-map.js"></script>
|
<script defer src="/components/world-map.js"></script>
|
||||||
|
<script defer src="/components/donut-chart.js"></script>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
<script>
|
<script>
|
||||||
const toggleRow = (event) => {
|
const toggleRow = (event) => {
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,20 @@
|
||||||
{{ icons::arrow_up_tray("h-4 w-4") }}
|
{{ icons::arrow_up_tray("h-4 w-4") }}
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</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
|
<button
|
||||||
type="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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
{% block content %}
|
||||||
<header class="flex flex-col gap-2">
|
<header class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
<h1 class="text-3xl font-semibold">Stats</h1>
|
<h1 class="text-3xl font-semibold">Stats</h1>
|
||||||
<p class="max-w-2xl text-sm text-text-secondary">Geographic overview of coffee data.</p>
|
{% 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>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
|
|
@ -12,4 +17,107 @@
|
||||||
{{ content|safe }}
|
{{ content|safe }}
|
||||||
</div>
|
</div>
|
||||||
</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 %}—{% 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 %}—{% 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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
31
templates/partials/histogram.html
Normal file
31
templates/partials/histogram.html
Normal 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 %}
|
||||||
Loading…
Reference in a new issue