From 7f99ffc993e2b22e52dbe31e2f999cfaed54c3af Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Sun, 8 Feb 2026 11:03:15 +0000 Subject: [PATCH] feat(stats): add geographic stats page with choropleth world map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /stats page with Roasters, Roasts, Cups, and Cafes tabs - Interactive choropleth world map (SVG) colored by country counts - Clickable country chips that highlight individual countries on the map - Horizontal chip scroller with chevron navigation on desktop - Datastar-powered tab switching without page reload - Domain layer: country name β†’ ISO code mapping, flag emoji generation - StatsRepository trait with four aggregate SQL queries - and custom elements for Datastar compatibility --- src/application/routes/app/mod.rs | 13 ++ src/application/routes/app/stats.rs | 140 +++++++++++++++++++ src/application/state.rs | 7 +- src/domain/country_stats.rs | 163 +++++++++++++++++++++++ src/domain/mod.rs | 1 + src/domain/repositories.rs | 8 ++ src/infrastructure/repositories/mod.rs | 1 + src/infrastructure/repositories/stats.rs | 90 +++++++++++++ src/presentation/web/templates.rs | 22 +++ static/js/components/world-map.js | 127 ++++++++++++++++++ templates/base.html | 1 + templates/pages/stats.html | 15 +++ templates/partials/icons.html | 6 + templates/partials/stats_map.html | 58 ++++++++ 14 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 src/application/routes/app/stats.rs create mode 100644 src/domain/country_stats.rs create mode 100644 src/infrastructure/repositories/stats.rs create mode 100644 static/js/components/world-map.js create mode 100644 templates/pages/stats.html create mode 100644 templates/partials/stats_map.html diff --git a/src/application/routes/app/mod.rs b/src/application/routes/app/mod.rs index cacc3f7..d4e77ec 100644 --- a/src/application/routes/app/mod.rs +++ b/src/application/routes/app/mod.rs @@ -4,6 +4,7 @@ pub(super) mod auth; mod checkin; mod data; mod home; +mod stats; mod timeline; mod webauthn; @@ -25,6 +26,7 @@ pub(super) fn router() -> axum::Router { .route("/scan", get(scan_redirect)) .route("/check-in", get(checkin::checkin_page)) .route("/timeline", get(timeline::timeline_page)) + .route("/stats", get(stats::stats_page)) .route("/styles.css", get(styles)) .route("/webauthn.js", get(webauthn_js)) .route("/components/photo-capture.js", get(photo_capture_js)) @@ -32,6 +34,7 @@ pub(super) fn router() -> axum::Router { "/components/searchable-select.js", get(searchable_select_js), ) + .route("/components/world-map.js", get(world_map_js)) .route("/favicon-light.svg", get(favicon_light)) .route("/favicon-dark.svg", get(favicon_dark)) } @@ -80,6 +83,16 @@ async fn searchable_select_js() -> impl IntoResponse { ) } +async fn world_map_js() -> impl IntoResponse { + ( + [ + ("content-type", "application/javascript; charset=utf-8"), + ("cache-control", "public, max-age=604800"), + ], + include_str!("../../../../static/js/components/world-map.js"), + ) +} + async fn favicon_light() -> impl IntoResponse { ( [ diff --git a/src/application/routes/app/stats.rs b/src/application/routes/app/stats.rs new file mode 100644 index 0000000..094b846 --- /dev/null +++ b/src/application/routes/app/stats.rs @@ -0,0 +1,140 @@ +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; + +use crate::application::errors::{AppError, map_app_error}; +use crate::application::routes::render_html; +use crate::application::routes::support::is_datastar_request; +use crate::application::state::AppState; +use crate::domain::country_stats::{CountryStat, GeoStats, country_to_iso, iso_to_flag_emoji}; +use crate::presentation::web::templates::{ + StatsMapFragment, StatsPageTemplate, Tab, render_template, +}; + +const TABS: &[Tab] = &[ + Tab { + key: "roasters", + label: "Roasters", + }, + Tab { + key: "roasts", + label: "Roasts", + }, + Tab { + key: "cups", + label: "Cups", + }, + Tab { + key: "cafes", + label: "Cafes", + }, +]; + +#[derive(Debug, Deserialize)] +pub(crate) struct StatsQuery { + #[serde(rename = "type", default = "default_type")] + entity_type: String, +} + +fn default_type() -> String { + "roasters".to_string() +} + +#[tracing::instrument(skip(state, cookies, headers, stats_query))] +pub(crate) async fn stats_page( + State(state): State, + cookies: tower_cookies::Cookies, + headers: HeaderMap, + Query(stats_query): Query, +) -> Result { + 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 + })?; + + if is_datastar_request(&headers) { + use axum::http::header::HeaderValue; + use axum::response::Html; + + let mut response = Html(content).into_response(); + response.headers_mut().insert( + "datastar-selector", + HeaderValue::from_static("#stats-content"), + ); + response + .headers_mut() + .insert("datastar-mode", HeaderValue::from_static("inner")); + return Ok(response); + } + + let tabs: Vec = TABS + .iter() + .map(|t| Tab { + key: t.key, + label: t.label, + }) + .collect(); + + let template = StatsPageTemplate { + nav_active: "stats", + is_authenticated, + version_info: &crate::VERSION_INFO, + active_type: entity_type, + tabs, + tab_signal: "_active-tab", + tab_signal_js: "$_activeTab", + tab_base_url: "/stats?type=", + tab_fetch_target: "#stats-content", + tab_fetch_mode: "inner", + content, + }; + + 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, + }) +} diff --git a/src/application/state.rs b/src/application/state.rs index 222bcd0..9f83f95 100644 --- a/src/application/state.rs +++ b/src/application/state.rs @@ -8,7 +8,8 @@ use crate::application::services::{ use crate::domain::repositories::{ AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository, PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, - RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, + RoasterRepository, SessionRepository, StatsRepository, TimelineEventRepository, + TokenRepository, UserRepository, }; use crate::infrastructure::backup::BackupService; use crate::infrastructure::database::Database; @@ -23,6 +24,7 @@ use crate::infrastructure::repositories::registration_tokens::SqlRegistrationTok use crate::infrastructure::repositories::roasters::SqlRoasterRepository; use crate::infrastructure::repositories::roasts::SqlRoastRepository; use crate::infrastructure::repositories::sessions::SqlSessionRepository; +use crate::infrastructure::repositories::stats::SqlStatsRepository; use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; use crate::infrastructure::repositories::tokens::SqlTokenRepository; use crate::infrastructure::repositories::users::SqlUserRepository; @@ -56,6 +58,7 @@ pub struct AppState { pub passkey_repo: Arc, pub registration_token_repo: Arc, pub ai_usage_repo: Arc, + pub stats_repo: Arc, pub webauthn: Arc, pub challenge_store: Arc, pub http_client: reqwest::Client, @@ -100,6 +103,7 @@ impl AppState { Arc::new(SqlRegistrationTokenRepository::new(pool.clone())); let ai_usage_repo: Arc = Arc::new(SqlAiUsageRepository::new(pool.clone())); + let stats_repo: Arc = Arc::new(SqlStatsRepository::new(pool.clone())); let backup_service = Arc::new(BackupService::new(pool)); @@ -136,6 +140,7 @@ impl AppState { passkey_repo, registration_token_repo, ai_usage_repo, + stats_repo, webauthn: config.webauthn, challenge_store: Arc::new(ChallengeStore::new()), #[allow(clippy::expect_used)] diff --git a/src/domain/country_stats.rs b/src/domain/country_stats.rs new file mode 100644 index 0000000..457e4f8 --- /dev/null +++ b/src/domain/country_stats.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; +use std::sync::LazyLock; + +/// A single country's count for geographic statistics. +#[derive(Debug, Clone)] +pub struct CountryStat { + pub country_name: String, + pub iso_code: String, + pub flag_emoji: String, + pub count: u64, +} + +/// Aggregated geographic stats for one entity type. +#[derive(Debug, Clone)] +pub struct GeoStats { + pub entries: Vec, + pub total_countries: usize, + pub max_count: u64, +} + +static COUNTRY_MAP: LazyLock> = LazyLock::new(|| { + HashMap::from([ + // Coffee-producing countries + ("ethiopia", "ET"), + ("colombia", "CO"), + ("kenya", "KE"), + ("brazil", "BR"), + ("guatemala", "GT"), + ("costa rica", "CR"), + ("rwanda", "RW"), + ("honduras", "HN"), + ("panama", "PA"), + ("mexico", "MX"), + ("el salvador", "SV"), + ("bolivia", "BO"), + ("peru", "PE"), + ("indonesia", "ID"), + ("india", "IN"), + ("vietnam", "VN"), + ("myanmar", "MM"), + ("china", "CN"), + ("papua new guinea", "PG"), + ("tanzania", "TZ"), + ("uganda", "UG"), + ("burundi", "BI"), + ("democratic republic of the congo", "CD"), + ("drc", "CD"), + ("congo", "CD"), + ("republic of the congo", "CG"), + ("yemen", "YE"), + ("nicaragua", "NI"), + ("ecuador", "EC"), + ("dominican republic", "DO"), + ("haiti", "HT"), + ("jamaica", "JM"), + ("thailand", "TH"), + ("laos", "LA"), + ("philippines", "PH"), + ("taiwan", "TW"), + // European roaster/cafe countries + ("united kingdom", "GB"), + ("uk", "GB"), + ("england", "GB"), + ("scotland", "GB"), + ("wales", "GB"), + ("northern ireland", "GB"), + ("germany", "DE"), + ("france", "FR"), + ("spain", "ES"), + ("italy", "IT"), + ("netherlands", "NL"), + ("belgium", "BE"), + ("denmark", "DK"), + ("sweden", "SE"), + ("norway", "NO"), + ("finland", "FI"), + ("switzerland", "CH"), + ("austria", "AT"), + ("portugal", "PT"), + ("ireland", "IE"), + ("poland", "PL"), + ("czech republic", "CZ"), + ("czechia", "CZ"), + ("greece", "GR"), + ("slovenia", "SI"), + ("croatia", "HR"), + ("romania", "RO"), + ("hungary", "HU"), + // North America + ("united states", "US"), + ("united states of america", "US"), + ("usa", "US"), + ("us", "US"), + ("canada", "CA"), + // Asia-Pacific + ("japan", "JP"), + ("south korea", "KR"), + ("korea", "KR"), + ("australia", "AU"), + ("new zealand", "NZ"), + ("singapore", "SG"), + // Other + ("south africa", "ZA"), + ("turkey", "TR"), + ("israel", "IL"), + ("united arab emirates", "AE"), + ("uae", "AE"), + ]) +}); + +/// Maps a free-text country name to its ISO-3166-1 alpha-2 code. +pub fn country_to_iso(name: &str) -> Option<&'static str> { + COUNTRY_MAP + .get(name.trim().to_lowercase().as_str()) + .copied() +} + +/// Converts an ISO-3166-1 alpha-2 code to a flag emoji using regional indicator symbols. +pub fn iso_to_flag_emoji(code: &str) -> String { + code.chars() + .filter_map(|c| { + let upper = c.to_ascii_uppercase(); + if upper.is_ascii_uppercase() { + char::from_u32(0x1F1E6 + (upper as u32 - 'A' as u32)) + } else { + None + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn country_to_iso_normalises_case() { + assert_eq!(country_to_iso("Ethiopia"), Some("ET")); + assert_eq!(country_to_iso("ETHIOPIA"), Some("ET")); + assert_eq!(country_to_iso(" ethiopia "), Some("ET")); + } + + #[test] + fn country_to_iso_handles_aliases() { + assert_eq!(country_to_iso("United Kingdom"), Some("GB")); + assert_eq!(country_to_iso("UK"), Some("GB")); + assert_eq!(country_to_iso("England"), Some("GB")); + } + + #[test] + fn country_to_iso_returns_none_for_unknown() { + assert_eq!(country_to_iso("Blend"), None); + assert_eq!(country_to_iso("Multiple Origins"), None); + assert_eq!(country_to_iso(""), None); + } + + #[test] + fn iso_to_flag_emoji_produces_correct_flags() { + assert_eq!(iso_to_flag_emoji("GB"), "πŸ‡¬πŸ‡§"); + assert_eq!(iso_to_flag_emoji("US"), "πŸ‡ΊπŸ‡Έ"); + assert_eq!(iso_to_flag_emoji("ET"), "πŸ‡ͺπŸ‡Ή"); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 8066ad9..93a339d 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -2,6 +2,7 @@ pub mod ai_usage; pub mod bags; pub mod brews; pub mod cafes; +pub mod country_stats; pub mod cups; pub mod errors; pub mod gear; diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 5363576..7ae92ce 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -268,3 +268,11 @@ pub trait AiUsageRepository: Send + Sync { async fn insert(&self, usage: NewAiUsage) -> Result; async fn summary_for_user(&self, user_id: UserId) -> Result; } + +#[async_trait] +pub trait StatsRepository: Send + Sync { + async fn roaster_country_counts(&self) -> Result, RepositoryError>; + async fn roast_origin_counts(&self) -> Result, RepositoryError>; + async fn cup_country_counts(&self) -> Result, RepositoryError>; + async fn cafe_country_counts(&self) -> Result, RepositoryError>; +} diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index 49f901a..2b10216 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -11,6 +11,7 @@ pub mod registration_tokens; pub mod roasters; pub mod roasts; pub mod sessions; +pub mod stats; pub mod timeline_events; pub mod tokens; pub mod users; diff --git a/src/infrastructure/repositories/stats.rs b/src/infrastructure/repositories/stats.rs new file mode 100644 index 0000000..18ee52b --- /dev/null +++ b/src/infrastructure/repositories/stats.rs @@ -0,0 +1,90 @@ +use async_trait::async_trait; +use sqlx::query_as; + +use crate::domain::RepositoryError; +use crate::domain::repositories::StatsRepository; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlStatsRepository { + pool: DatabasePool, +} + +impl SqlStatsRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } +} + +#[derive(sqlx::FromRow)] +struct CountryCount { + country: String, + count: i64, +} + +impl CountryCount { + fn into_tuple(self) -> (String, u64) { + (self.country, self.count as u64) + } +} + +#[async_trait] +impl StatsRepository for SqlStatsRepository { + async fn roaster_country_counts(&self) -> Result, RepositoryError> { + let rows = query_as::<_, CountryCount>( + r"SELECT country, COUNT(*) as count + FROM roasters + GROUP BY country + ORDER BY count DESC", + ) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(rows.into_iter().map(CountryCount::into_tuple).collect()) + } + + async fn roast_origin_counts(&self) -> Result, RepositoryError> { + let rows = query_as::<_, CountryCount>( + r"SELECT origin as country, COUNT(*) as count + FROM roasts + WHERE origin IS NOT NULL AND origin != '' + GROUP BY origin + ORDER BY count DESC", + ) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(rows.into_iter().map(CountryCount::into_tuple).collect()) + } + + async fn cup_country_counts(&self) -> Result, RepositoryError> { + let rows = query_as::<_, CountryCount>( + r"SELECT ca.country as country, COUNT(*) as count + FROM cups c + JOIN cafes ca ON c.cafe_id = ca.id + GROUP BY ca.country + ORDER BY count DESC", + ) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(rows.into_iter().map(CountryCount::into_tuple).collect()) + } + + async fn cafe_country_counts(&self) -> Result, RepositoryError> { + let rows = query_as::<_, CountryCount>( + r"SELECT country, COUNT(*) as count + FROM cafes + GROUP BY country + ORDER BY count DESC", + ) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(rows.into_iter().map(CountryCount::into_tuple).collect()) + } +} diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 73be525..0df727a 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -175,6 +175,28 @@ pub struct AddTemplate { pub pre_select_bag_id: Option, } +#[derive(Template)] +#[template(path = "pages/stats.html")] +pub struct StatsPageTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub version_info: &'static crate::VersionInfo, + pub active_type: String, + pub tabs: Vec, + pub tab_signal: &'static str, + pub tab_signal_js: &'static str, + pub tab_base_url: &'static str, + pub tab_fetch_target: &'static str, + pub tab_fetch_mode: &'static str, + pub content: String, +} + +#[derive(Template)] +#[template(path = "partials/stats_map.html")] +pub struct StatsMapFragment<'a> { + pub geo_stats: &'a crate::domain::country_stats::GeoStats, +} + pub fn render_template(template: T) -> Result { template.render() } diff --git a/static/js/components/world-map.js b/static/js/components/world-map.js new file mode 100644 index 0000000..2fcc061 --- /dev/null +++ b/static/js/components/world-map.js @@ -0,0 +1,127 @@ +// World map web component for brewlog stats page. +// Map data: Al MacDonald, edited by Fritz Lekschas. License: CC BY-SA 3.0 +const WORLD_SVG = ` +Author: Al MacDonald Editor: Fritz Lekschas License: CC BY-SA 3.0 ID: ISO 3166-1 or "_[a-zA-Z]" if an ISO code is not available`; + +customElements.define("world-map", class extends HTMLElement { + static get observedAttributes() { return ["data-countries", "data-max", "data-selected"]; } + + connectedCallback() { this._scheduleRender(); } + + attributeChangedCallback(name) { + if (!this.isConnected) return; + if (name === "data-selected") { + this._recolor(); + } else { + this._scheduleRender(); + } + } + + _scheduleRender() { + if (this._pendingRender) return; + this._pendingRender = true; + requestAnimationFrame(() => { + this._pendingRender = false; + this._render(); + }); + } + + _parseCountries() { + const attr = this.getAttribute("data-countries") || ""; + const counts = new Map(); + if (attr) { + attr.split(",").forEach((pair) => { + const [code, count] = pair.split(":"); + if (code && count) counts.set(code.trim().toLowerCase(), parseInt(count, 10)); + }); + } + this._counts = counts; + this._max = parseInt(this.getAttribute("data-max") || "1", 10) || 1; + } + + _render() { + this._parseCountries(); + + this.innerHTML = ""; + const container = document.createElement("div"); + container.innerHTML = WORLD_SVG; + const svg = container.querySelector("svg"); + if (!svg) return; + svg.style.width = "100%"; + svg.style.height = "auto"; + svg.style.display = "block"; + this.appendChild(svg); + + this._recolor(); + } + + _recolor() { + const svg = this.querySelector("svg"); + if (!svg) return; + if (!this._counts) this._parseCountries(); + + const selected = (this.getAttribute("data-selected") || "").toLowerCase(); + const counts = this._counts; + const max = this._max; + + const applyStyle = (el, fill) => { + el.style.fill = fill; + el.style.stroke = "#9ca3af"; + el.style.strokeWidth = "0.3"; + }; + + const colorFor = (code) => { + const count = counts.get(code); + if (selected) { + if (code === selected && count) return "rgba(185, 28, 28, 1)"; + return count ? "#d6d3d1" : "#f5f5f4"; + } + if (count) { + const alpha = (0.15 + 0.85 * (count / max)).toFixed(2); + return `rgba(220, 38, 38, ${alpha})`; + } + return "#f5f5f4"; + }; + + svg.querySelectorAll("path[id], g[id]").forEach((el) => { + const code = el.id.toLowerCase(); + const fill = colorFor(code); + if (el.tagName === "g") { + el.querySelectorAll("path").forEach((p) => applyStyle(p, fill)); + } else { + applyStyle(el, fill); + } + }); + } +}); + +customElements.define("chip-scroll", class extends HTMLElement { + connectedCallback() { this._setup(); } + + _setup() { + if (this._observer) this._observer.disconnect(); + + const scroller = this.querySelector("[data-chip-scroll]"); + const btnL = this.querySelector("[data-scroll-left]"); + const btnR = this.querySelector("[data-scroll-right]"); + if (!scroller || !btnL || !btnR) return; + + const update = () => { + if (window.matchMedia("(max-width: 767px)").matches) { + btnL.style.display = "none"; + btnR.style.display = "none"; + return; + } + btnL.style.display = scroller.scrollLeft > 0 ? "flex" : "none"; + btnR.style.display = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1 ? "flex" : "none"; + }; + + scroller.addEventListener("scroll", update, { passive: true }); + new ResizeObserver(update).observe(scroller); + + this._observer = new MutationObserver(update); + this._observer.observe(scroller, { childList: true }); + + update(); + } +}); diff --git a/templates/base.html b/templates/base.html index b3e8000..1e0a7ab 100644 --- a/templates/base.html +++ b/templates/base.html @@ -27,6 +27,7 @@ > + {% block head %}{% endblock %}