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:
parent
6245a2a42d
commit
7f99ffc993
14 changed files with 651 additions and 1 deletions
|
|
@ -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 {
|
||||
(
|
||||
[
|
||||
|
|
|
|||
140
src/application/routes/app/stats.rs
Normal file
140
src/application/routes/app/stats.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
163
src/domain/country_stats.rs
Normal 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"), "🇪🇹");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
90
src/infrastructure/repositories/stats.rs
Normal file
90
src/infrastructure/repositories/stats.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
127
static/js/components/world-map.js
Normal file
127
static/js/components/world-map.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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) => {
|
||||
|
|
|
|||
15
templates/pages/stats.html
Normal file
15
templates/pages/stats.html
Normal 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 %}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
58
templates/partials/stats_map.html
Normal file
58
templates/partials/stats_map.html
Normal 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>
|
||||
Loading…
Reference in a new issue