From b17aaab5f4e425cee7240c2ed6b79ce5fb8d1284 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Sun, 8 Feb 2026 16:10:01 +0000 Subject: [PATCH] feat(detail): add shareable brew and cup detail pages Add /brews/:id and /cups/:id routes with full coffee, roaster, gear, and recipe information. Each page includes a world map highlighting relevant countries, tasting note pills, and a share button that copies the URL to the clipboard. --- src/application/routes/app/brews.rs | 55 ++++++++ src/application/routes/app/cups.rs | 60 +++++++++ src/application/routes/app/mod.rs | 9 +- src/presentation/web/templates.rs | 25 +++- src/presentation/web/views/brews.rs | 127 ++++++++++++++++++ src/presentation/web/views/cups.rs | 117 +++++++++++++++++ src/presentation/web/views/mod.rs | 49 ++++++- templates/pages/brew.html | 192 ++++++++++++++++++++++++++++ templates/pages/cup.html | 166 ++++++++++++++++++++++++ 9 files changed, 791 insertions(+), 9 deletions(-) create mode 100644 src/application/routes/app/brews.rs create mode 100644 src/application/routes/app/cups.rs create mode 100644 templates/pages/brew.html create mode 100644 templates/pages/cup.html diff --git a/src/application/routes/app/brews.rs b/src/application/routes/app/brews.rs new file mode 100644 index 0000000..f69f18b --- /dev/null +++ b/src/application/routes/app/brews.rs @@ -0,0 +1,55 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use tower_cookies::Cookies; + +use crate::application::errors::map_app_error; +use crate::application::routes::render_html; +use crate::application::state::AppState; +use crate::domain::ids::BrewId; +use crate::presentation::web::templates::BrewDetailTemplate; +use crate::presentation::web::views::BrewDetailView; + +#[tracing::instrument(skip(state, cookies))] +pub(crate) async fn brew_detail_page( + State(state): State, + cookies: Cookies, + Path(id): Path, +) -> Result { + let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; + + let brew_details = state + .brew_repo + .get_with_details(id) + .await + .map_err(|e| map_app_error(e.into()))?; + + let bag = state + .bag_repo + .get(brew_details.brew.bag_id) + .await + .map_err(|e| map_app_error(e.into()))?; + + let roast = state + .roast_repo + .get(bag.roast_id) + .await + .map_err(|e| map_app_error(e.into()))?; + + let roaster = state + .roaster_repo + .get(roast.roaster_id) + .await + .map_err(|e| map_app_error(e.into()))?; + + let view = BrewDetailView::from_parts(brew_details, &roast, &roaster); + + let template = BrewDetailTemplate { + nav_active: "", + is_authenticated, + version_info: &crate::VERSION_INFO, + brew: view, + }; + + render_html(template).map(IntoResponse::into_response) +} diff --git a/src/application/routes/app/cups.rs b/src/application/routes/app/cups.rs new file mode 100644 index 0000000..7335831 --- /dev/null +++ b/src/application/routes/app/cups.rs @@ -0,0 +1,60 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use tower_cookies::Cookies; + +use crate::application::errors::map_app_error; +use crate::application::routes::render_html; +use crate::application::state::AppState; +use crate::domain::ids::CupId; +use crate::presentation::web::templates::CupDetailTemplate; +use crate::presentation::web::views::CupDetailView; + +#[tracing::instrument(skip(state, cookies))] +pub(crate) async fn cup_detail_page( + State(state): State, + cookies: Cookies, + Path(id): Path, +) -> Result { + let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; + + let cup_details = state + .cup_repo + .get_with_details(id) + .await + .map_err(|e| map_app_error(e.into()))?; + + let (roast, cafe) = tokio::try_join!( + async { + state + .roast_repo + .get(cup_details.cup.roast_id) + .await + .map_err(|e| map_app_error(e.into())) + }, + async { + state + .cafe_repo + .get(cup_details.cup.cafe_id) + .await + .map_err(|e| map_app_error(e.into())) + }, + )?; + + let roaster = state + .roaster_repo + .get(roast.roaster_id) + .await + .map_err(|e| map_app_error(e.into()))?; + + let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe); + + let template = CupDetailTemplate { + nav_active: "", + is_authenticated, + version_info: &crate::VERSION_INFO, + cup: view, + }; + + render_html(template).map(IntoResponse::into_response) +} diff --git a/src/application/routes/app/mod.rs b/src/application/routes/app/mod.rs index 344934b..8ed907a 100644 --- a/src/application/routes/app/mod.rs +++ b/src/application/routes/app/mod.rs @@ -1,7 +1,9 @@ mod add; mod admin; pub(super) mod auth; +mod brews; mod checkin; +mod cups; mod data; mod home; mod stats; @@ -27,6 +29,8 @@ pub(super) fn router() -> axum::Router { .route("/check-in", get(checkin::checkin_page)) .route("/timeline", get(timeline::timeline_page)) .route("/stats", get(stats::stats_page)) + .route("/brews/{id}", get(brews::brew_detail_page)) + .route("/cups/{id}", get(cups::cup_detail_page)) .route("/styles.css", get(styles)) .route("/webauthn.js", get(webauthn_js)) .route("/components/photo-capture.js", get(photo_capture_js)) @@ -137,8 +141,5 @@ async fn favicon_dark() -> impl IntoResponse { } async fn health() -> impl IntoResponse { - ( - [("content-type", "application/json")], - r#"{"status":"ok"}"#, - ) + ([("content-type", "application/json")], r#"{"status":"ok"}"#) } diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index e062231..72bc0fb 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -1,9 +1,9 @@ use askama::Template; use super::views::{ - BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView, - GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView, - RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView, + BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, CafeOptionView, CafeView, + CupDetailView, CupView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, + QuickNoteView, RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView, TimelineEventView, TimelineMonthView, }; use crate::domain::bags::BagSortKey; @@ -199,6 +199,7 @@ pub struct StatsPageTemplate { pub consumption_30d_weight: String, pub consumption_all_time_weight: String, pub cache_age: String, + pub has_data: bool, } #[derive(Template)] @@ -207,6 +208,24 @@ pub struct StatsMapFragment<'a> { pub geo_stats: &'a crate::domain::country_stats::GeoStats, } +#[derive(Template)] +#[template(path = "pages/brew.html")] +pub struct BrewDetailTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub version_info: &'static crate::VersionInfo, + pub brew: BrewDetailView, +} + +#[derive(Template)] +#[template(path = "pages/cup.html")] +pub struct CupDetailTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub version_info: &'static crate::VersionInfo, + pub cup: CupDetailView, +} + pub fn render_template(template: T) -> Result { template.render() } diff --git a/src/presentation/web/views/brews.rs b/src/presentation/web/views/brews.rs index 8c7df3e..aa995e2 100644 --- a/src/presentation/web/views/brews.rs +++ b/src/presentation/web/views/brews.rs @@ -1,9 +1,14 @@ use std::fmt::Write; use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time}; +use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; use crate::domain::formatting::format_weight; +use crate::domain::roasters::Roaster; +use crate::domain::roasts::Roast; +use super::build_map_data; use super::relative_date; +use super::tasting_notes::{self, TastingNoteView}; #[derive(Clone)] pub struct QuickNoteView { @@ -192,6 +197,128 @@ impl Default for BrewDefaultsView { } } +pub struct BrewDetailView { + // Coffee info + pub roast_name: String, + pub roaster_name: String, + pub origin: String, + pub origin_flag: String, + pub region: String, + pub producer: String, + pub process: String, + pub tasting_notes: Vec, + // Roaster info + pub roaster_country: String, + pub roaster_country_flag: String, + pub roaster_city: Option, + pub roaster_homepage: Option, + // Recipe + pub coffee_weight: String, + pub water_volume: String, + pub water_temp: String, + pub grind_setting: String, + pub brew_time: Option, + pub quick_notes_label: String, + // Gear + pub grinder_name: String, + pub brewer_name: String, + pub filter_paper_name: Option, + // Map + pub map_countries: String, + pub map_max: u32, + // Dates + pub created_date: String, + pub created_time: String, +} + +impl BrewDetailView { + pub fn from_parts(brew: BrewWithDetails, roast: &Roast, roaster: &Roaster) -> Self { + let em_dash = "\u{2014}".to_string(); + + let origin = roast.origin.clone().unwrap_or_default(); + let origin_flag = country_to_iso(&origin) + .map(iso_to_flag_emoji) + .unwrap_or_default(); + + let roaster_country_flag = country_to_iso(&roaster.country) + .map(iso_to_flag_emoji) + .unwrap_or_default(); + + let mut map_entries: Vec<(&str, u32)> = Vec::new(); + if let Some(ref o) = roast.origin + && !o.is_empty() + { + map_entries.push((o.as_str(), 2)); + } + map_entries.push((roaster.country.as_str(), 1)); + let (map_countries, map_max) = build_map_data(&map_entries); + + let tasting_notes = roast + .tasting_notes + .iter() + .flat_map(|note| { + note.split([',', '\n']) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .map(|n| tasting_notes::categorize(&n)) + .collect(); + + let quick_notes_label = brew + .brew + .quick_notes + .iter() + .map(|n| n.label()) + .collect::>() + .join(", "); + + Self { + roast_name: brew.roast_name, + roaster_name: brew.roaster_name, + origin: if origin.is_empty() { + em_dash.clone() + } else { + origin + }, + origin_flag, + region: roast + .region + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or(em_dash.clone()), + producer: roast + .producer + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or(em_dash.clone()), + process: roast + .process + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or(em_dash), + tasting_notes, + roaster_country: roaster.country.clone(), + roaster_country_flag, + roaster_city: roaster.city.clone(), + roaster_homepage: roaster.homepage.clone(), + coffee_weight: format_weight(brew.brew.coffee_weight), + water_volume: format!("{}ml", brew.brew.water_volume), + water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp), + grind_setting: format!("{:.1}", brew.brew.grind_setting), + brew_time: brew.brew.brew_time.map(format_brew_time), + quick_notes_label, + grinder_name: brew.grinder_name, + brewer_name: brew.brewer_name, + filter_paper_name: brew.filter_paper_name, + map_countries, + map_max, + created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(), + created_time: brew.brew.created_at.format("%H:%M").to_string(), + } + } +} + impl From for BrewDefaultsView { fn from(brew: BrewWithDetails) -> Self { Self { diff --git a/src/presentation/web/views/cups.rs b/src/presentation/web/views/cups.rs index 70cd36e..fdfed75 100644 --- a/src/presentation/web/views/cups.rs +++ b/src/presentation/web/views/cups.rs @@ -1,4 +1,11 @@ +use crate::domain::cafes::Cafe; +use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; use crate::domain::cups::CupWithDetails; +use crate::domain::roasters::Roaster; +use crate::domain::roasts::Roast; + +use super::build_map_data; +use super::tasting_notes::{self, TastingNoteView}; #[derive(Clone)] pub struct CupView { @@ -30,3 +37,113 @@ impl CupView { } } } + +pub struct CupDetailView { + // Coffee info + pub roast_name: String, + pub roaster_name: String, + pub origin: String, + pub origin_flag: String, + pub region: String, + pub producer: String, + pub process: String, + pub tasting_notes: Vec, + // Roaster info + pub roaster_country: String, + pub roaster_country_flag: String, + pub roaster_city: Option, + pub roaster_homepage: Option, + // Cafe info + pub cafe_name: String, + pub cafe_city: String, + pub cafe_country: String, + pub cafe_country_flag: String, + pub cafe_website: Option, + // Map + pub map_countries: String, + pub map_max: u32, + // Dates + pub created_date: String, + pub created_time: String, +} + +impl CupDetailView { + pub fn from_parts(cup: CupWithDetails, roast: &Roast, roaster: &Roaster, cafe: &Cafe) -> Self { + let em_dash = "\u{2014}".to_string(); + + let origin = roast.origin.clone().unwrap_or_default(); + let origin_flag = country_to_iso(&origin) + .map(iso_to_flag_emoji) + .unwrap_or_default(); + + let roaster_country_flag = country_to_iso(&roaster.country) + .map(iso_to_flag_emoji) + .unwrap_or_default(); + + let cafe_country_flag = country_to_iso(&cafe.country) + .map(iso_to_flag_emoji) + .unwrap_or_default(); + + let mut map_entries: Vec<(&str, u32)> = Vec::new(); + map_entries.push((cafe.country.as_str(), 3)); + if let Some(ref o) = roast.origin + && !o.is_empty() + { + map_entries.push((o.as_str(), 2)); + } + map_entries.push((roaster.country.as_str(), 1)); + let (map_countries, map_max) = build_map_data(&map_entries); + + let tasting_notes = roast + .tasting_notes + .iter() + .flat_map(|note| { + note.split([',', '\n']) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .map(|n| tasting_notes::categorize(&n)) + .collect(); + + Self { + roast_name: cup.roast_name, + roaster_name: cup.roaster_name, + origin: if origin.is_empty() { + em_dash.clone() + } else { + origin + }, + origin_flag, + region: roast + .region + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or(em_dash.clone()), + producer: roast + .producer + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or(em_dash.clone()), + process: roast + .process + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or(em_dash), + tasting_notes, + roaster_country: roaster.country.clone(), + roaster_country_flag, + roaster_city: roaster.city.clone(), + roaster_homepage: roaster.homepage.clone(), + cafe_name: cafe.name.clone(), + cafe_city: cafe.city.clone(), + cafe_country: cafe.country.clone(), + cafe_country_flag, + cafe_website: cafe.website.clone(), + map_countries, + map_max, + created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(), + created_time: cup.cup.created_at.format("%H:%M").to_string(), + } + } +} diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index e536ad7..47f6dcf 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -9,9 +9,9 @@ pub mod tasting_notes; mod timeline; pub use bags::{BagOptionView, BagView}; -pub use brews::{BrewDefaultsView, BrewView, QuickNoteView}; +pub use brews::{BrewDefaultsView, BrewDetailView, BrewView, QuickNoteView}; pub use cafes::{CafeOptionView, CafeView, NearbyCafeView}; -pub use cups::CupView; +pub use cups::{CupDetailView, CupView}; pub use gear::{GearOptionView, GearView}; pub use roasters::{RoasterOptionView, RoasterView}; pub use roasts::{RoastOptionView, RoastView}; @@ -29,6 +29,17 @@ pub struct StatsView { pub bags: u64, } +impl StatsView { + pub fn is_empty(&self) -> bool { + self.brews == 0 + && self.roasts == 0 + && self.roasters == 0 + && self.cups == 0 + && self.cafes == 0 + && self.bags == 0 + } +} + pub struct StatCard { pub icon: &'static str, pub value: String, @@ -349,3 +360,37 @@ fn page_size_from_text(value: &str) -> PageSize { PageSize::limited(DEFAULT_PAGE_SIZE) } } + +/// Build `data-countries` and `data-max` values for the world-map component. +/// +/// Accepts `(country_name, weight)` pairs where higher weights render darker. +/// Resolves country names to ISO codes, deduplicates by keeping the highest weight +/// per ISO code, and returns the attribute string and max value. +pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) { + use std::collections::HashMap; + + use crate::domain::countries::country_to_iso; + + let mut iso_weights: HashMap<&str, u32> = HashMap::new(); + + for &(country_name, weight) in entries { + if country_name.is_empty() { + continue; + } + if let Some(iso) = country_to_iso(country_name) { + let entry = iso_weights.entry(iso).or_insert(0); + *entry = (*entry).max(weight); + } + } + + let mut max = 0u32; + let parts: Vec = iso_weights + .iter() + .map(|(iso, &w)| { + max = max.max(w); + format!("{iso}:{w}") + }) + .collect(); + + (parts.join(","), max) +} diff --git a/templates/pages/brew.html b/templates/pages/brew.html new file mode 100644 index 0000000..aefc0b6 --- /dev/null +++ b/templates/pages/brew.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} +{% import "partials/icons.html" as icons %} +{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %} +{% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %} +{% block content %} +
+
+

{{ brew.roast_name }}

+

+ {{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }} +

+
+ +
+ + + +{# ── Coffee + map ── #} +
+
+

Coffee

+
+
+
Roast
+
{{ brew.roast_name }}
+
+
+
Roaster
+
{{ brew.roaster_name }}
+
+ {% if brew.origin != "\u{2014}" %} +
+
Origin
+
+ {% if !brew.origin_flag.is_empty() %}{{ brew.origin_flag }} {% endif %}{{ brew.origin }} +
+
+ {% endif %} + {% if brew.region != "\u{2014}" %} +
+
Region
+
{{ brew.region }}
+
+ {% endif %} + {% if brew.producer != "\u{2014}" %} +
+
Producer
+
{{ brew.producer }}
+
+ {% endif %} + {% if brew.process != "\u{2014}" %} +
+
Process
+
{{ brew.process }}
+
+ {% endif %} +
+ {% if !brew.tasting_notes.is_empty() %} +
+ {% for note in brew.tasting_notes %} + {{ note.label }} + {% endfor %} +
+ {% endif %} +
+ + {% if !brew.map_countries.is_empty() %} +
+ +
+ + Origin + + + Roaster + +
+
+ {% endif %} +
+ +{# ── Roaster & gear ── #} +
+
+

Roaster

+
+
+
Name
+
{{ brew.roaster_name }}
+
+
+
Country
+
+ {% if !brew.roaster_country_flag.is_empty() %}{{ brew.roaster_country_flag }} {% endif %}{{ brew.roaster_country }} +
+
+ {% if let Some(city) = brew.roaster_city %} +
+
City
+
{{ city }}
+
+ {% endif %} + {% if let Some(url) = brew.roaster_homepage %} +
+
Website
+
+ Visit Website +
+
+ {% endif %} +
+
+ +
+

Gear

+
+
+
Grinder
+
{{ brew.grinder_name }}
+
+
+
Brewer
+
{{ brew.brewer_name }}
+
+ {% if let Some(fp) = brew.filter_paper_name %} +
+
Filter Paper
+
{{ fp }}
+
+ {% endif %} +
+
+
+ +{# ── Recipe ── #} +
+
+

Recipe

+
+
+
Coffee
+
{{ brew.coffee_weight }}
+
+
+
Water
+
{{ brew.water_volume }}
+
+
+
Temperature
+
{{ brew.water_temp }}
+
+
+
Grind Setting
+
{{ brew.grind_setting }}
+
+ {% if let Some(time) = brew.brew_time %} +
+
Brew Time
+
{{ time }}
+
+ {% endif %} + {% if !brew.quick_notes_label.is_empty() %} +
+
Notes
+
{{ brew.quick_notes_label }}
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/pages/cup.html b/templates/pages/cup.html new file mode 100644 index 0000000..9ee49f8 --- /dev/null +++ b/templates/pages/cup.html @@ -0,0 +1,166 @@ +{% extends "base.html" %} +{% import "partials/icons.html" as icons %} +{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %} +{% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %} +{% block content %} +
+
+

{{ cup.roast_name }}

+

+ {{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }} +

+
+ +
+ + + +{# ── Coffee + map ── #} +
+
+

Coffee

+
+
+
Roast
+
{{ cup.roast_name }}
+
+
+
Roaster
+
{{ cup.roaster_name }}
+
+ {% if cup.origin != "\u{2014}" %} +
+
Origin
+
+ {% if !cup.origin_flag.is_empty() %}{{ cup.origin_flag }} {% endif %}{{ cup.origin }} +
+
+ {% endif %} + {% if cup.region != "\u{2014}" %} +
+
Region
+
{{ cup.region }}
+
+ {% endif %} + {% if cup.producer != "\u{2014}" %} +
+
Producer
+
{{ cup.producer }}
+
+ {% endif %} + {% if cup.process != "\u{2014}" %} +
+
Process
+
{{ cup.process }}
+
+ {% endif %} +
+ {% if !cup.tasting_notes.is_empty() %} +
+ {% for note in cup.tasting_notes %} + {{ note.label }} + {% endfor %} +
+ {% endif %} +
+ + {% if !cup.map_countries.is_empty() %} +
+ +
+ + Cafe + + + Origin + + + Roaster + +
+
+ {% endif %} +
+ +{# ── Roaster & cafe ── #} +
+
+

Roaster

+
+
+
Name
+
{{ cup.roaster_name }}
+
+
+
Country
+
+ {% if !cup.roaster_country_flag.is_empty() %}{{ cup.roaster_country_flag }} {% endif %}{{ cup.roaster_country }} +
+
+ {% if let Some(city) = cup.roaster_city %} +
+
City
+
{{ city }}
+
+ {% endif %} + {% if let Some(url) = cup.roaster_homepage %} +
+
Website
+
+ Visit Website +
+
+ {% endif %} +
+
+ +
+

Cafe

+
+
+
Name
+
{{ cup.cafe_name }}
+
+
+
City
+
{{ cup.cafe_city }}
+
+
+
Country
+
+ {% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }} {% endif %}{{ cup.cafe_country }} +
+
+ {% if let Some(url) = cup.cafe_website %} +
+
Website
+
+ Visit Website +
+
+ {% endif %} +
+
+
+{% endblock %}