feat(stats): add geographic stats page with choropleth world map

- 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
- <world-map> and <chip-scroll> custom elements for Datastar compatibility
This commit is contained in:
Jon Seager 2026-02-08 11:03:15 +00:00
parent 6245a2a42d
commit 7f99ffc993
No known key found for this signature in database
14 changed files with 651 additions and 1 deletions

View file

@ -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<AppState> {
.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<AppState> {
"/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 {
(
[

View file

@ -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<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(stats_query): Query<StatsQuery>,
) -> Result<Response, StatusCode> {
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<Tab> = 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<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,
})
}

View file

@ -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<dyn PasskeyCredentialRepository>,
pub registration_token_repo: Arc<dyn RegistrationTokenRepository>,
pub ai_usage_repo: Arc<dyn AiUsageRepository>,
pub stats_repo: Arc<dyn StatsRepository>,
pub webauthn: Arc<Webauthn>,
pub challenge_store: Arc<ChallengeStore>,
pub http_client: reqwest::Client,
@ -100,6 +103,7 @@ impl AppState {
Arc::new(SqlRegistrationTokenRepository::new(pool.clone()));
let ai_usage_repo: Arc<dyn AiUsageRepository> =
Arc::new(SqlAiUsageRepository::new(pool.clone()));
let stats_repo: Arc<dyn StatsRepository> = 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)]

163
src/domain/country_stats.rs Normal file
View file

@ -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<CountryStat>,
pub total_countries: usize,
pub max_count: u64,
}
static COUNTRY_MAP: LazyLock<HashMap<&'static str, &'static str>> = 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"), "🇪🇹");
}
}

View file

@ -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;

View file

@ -268,3 +268,11 @@ pub trait AiUsageRepository: Send + Sync {
async fn insert(&self, usage: NewAiUsage) -> Result<AiUsage, RepositoryError>;
async fn summary_for_user(&self, user_id: UserId) -> Result<AiUsageSummary, RepositoryError>;
}
#[async_trait]
pub trait StatsRepository: Send + Sync {
async fn roaster_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
async fn roast_origin_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
async fn cup_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
async fn cafe_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
}

View file

@ -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;

View file

@ -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<Vec<(String, u64)>, 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<Vec<(String, u64)>, 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<Vec<(String, u64)>, 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<Vec<(String, u64)>, 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())
}
}

View file

@ -175,6 +175,28 @@ pub struct AddTemplate {
pub pre_select_bag_id: Option<String>,
}
#[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<Tab>,
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<T: Template>(template: T) -> Result<String, askama::Error> {
template.render()
}

File diff suppressed because one or more lines are too long

View file

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

View file

@ -0,0 +1,15 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% 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>
</header>
<div class="flex flex-col gap-6">
{% include "partials/tab_bar.html" %}
<div id="stats-content">
{{ content|safe }}
</div>
</div>
{% endblock %}

View file

@ -205,6 +205,12 @@
</svg>
{% endmacro %}
{% macro chevron_left(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
{% endmacro %}
{% macro chevron_right(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />

View file

@ -0,0 +1,58 @@
{% import "partials/icons.html" as icons %}
<div class="flex flex-col gap-4">
{% if geo_stats.entries.is_empty() %}
<div class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary">
<p class="text-center">No data recorded yet.</p>
</div>
{% else %}
<div class="rounded-lg border bg-surface overflow-hidden">
<world-map
class="block w-full"
data-countries="{% for entry in geo_stats.entries %}{% if !entry.iso_code.is_empty() %}{% if !loop.first %},{% endif %}{{ entry.iso_code }}:{{ entry.count }}{% endif %}{% endfor %}"
data-max="{{ geo_stats.max_count }}"
></world-map>
</div>
<chip-scroll class="relative block">
<button type="button" aria-label="Scroll left"
class="hidden absolute left-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 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-4 w-4") }}
</button>
<div class="flex gap-2 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide" data-chip-scroll>
{% for entry in geo_stats.entries %}
{% if !entry.iso_code.is_empty() %}
<button type="button"
class="inline-flex shrink-0 snap-start items-center rounded-full border text-sm transition cursor-pointer"
data-iso="{{ entry.iso_code|lower }}"
onclick="const map = document.querySelector('world-map'); const iso = this.dataset.iso; const cur = map.getAttribute('data-selected'); if (cur === iso) { map.removeAttribute('data-selected'); } else { map.setAttribute('data-selected', iso); } document.querySelectorAll('[data-iso]').forEach(b => b.classList.toggle('bg-accent-subtle', b.dataset.iso === map.getAttribute('data-selected')));">
<span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2">
{% if !entry.flag_emoji.is_empty() %}
<span>{{ entry.flag_emoji }}</span>
{% endif %}
<span class="text-text whitespace-nowrap">{{ entry.country_name }}</span>
</span>
<span class="border-l py-1.5 pl-2 pr-3 font-medium text-text">{{ entry.count }}</span>
</button>
{% else %}
<div class="inline-flex shrink-0 snap-start items-center rounded-full border text-sm">
<span class="inline-flex items-center gap-1.5 py-1.5 pl-3 pr-2">
<span class="text-text whitespace-nowrap">{{ entry.country_name }}</span>
</span>
<span class="border-l py-1.5 pl-2 pr-3 font-medium text-text">{{ entry.count }}</span>
</div>
{% endif %}
{% endfor %}
</div>
<button type="button" aria-label="Scroll right"
class="hidden absolute right-0 top-1/2 z-10 -translate-y-1/2 items-center justify-center rounded-full border bg-surface p-1 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-4 w-4") }}
</button>
</chip-scroll>
<p class="text-xs text-text-muted">{{ geo_stats.total_countries }} {% if geo_stats.total_countries == 1 %}country{% else %}countries{% endif %}</p>
{% endif %}
</div>