From d708a0e112e94880cb9f99ad479f7fffba871ab9 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Thu, 27 Nov 2025 14:02:55 +0000 Subject: [PATCH] feat: add `bags` web views and templates --- src/application/routes/bags.rs | 134 ++++++++++++++++++ src/application/routes/mod.rs | 2 + src/application/routes/roasts.rs | 55 ++++++-- src/presentation/web/templates.rs | 34 ++++- src/presentation/web/views.rs | 84 ++++++++++-- templates/bags.html | 114 ++++++++++++++++ templates/nav.html | 3 +- templates/partials/bag_list.html | 127 ++++++++++++++++++ templates/partials/roast_list.html | 179 ++----------------------- templates/partials/roast_options.html | 5 + templates/partials/roaster_list.html | 155 +-------------------- templates/partials/table.html | 84 ++++++++++++ templates/partials/timeline_month.html | 8 +- templates/roast_detail.html | 52 +++++++ 14 files changed, 693 insertions(+), 343 deletions(-) create mode 100644 templates/bags.html create mode 100644 templates/partials/bag_list.html create mode 100644 templates/partials/roast_options.html create mode 100644 templates/partials/table.html diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index 01c1728..b9efda4 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -16,6 +16,84 @@ use crate::domain::ids::{BagId, RoastId}; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::RoasterSortKey; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; +use crate::presentation::web::templates::{BagListTemplate, BagsTemplate}; +use crate::presentation::web::views::{BagView, ListNavigator, Paginated, RoasterOptionView}; + +const BAG_PAGE_PATH: &str = "/bags"; +const BAG_FRAGMENT_PATH: &str = "/bags#bag-list"; + +#[tracing::instrument(skip(state))] +async fn load_bag_page( + state: &AppState, + request: ListRequest, +) -> Result<(Vec, Paginated, ListNavigator), AppError> { + let open_bags = state.bag_repo.list_open().await.map_err(AppError::from)?; + let open_bags_view = open_bags + .into_iter() + .map(BagView::from_with_roast) + .collect(); + + let page = state + .bag_repo + .list_closed(&request) + .await + .map_err(AppError::from)?; + + let (bags, navigator) = crate::application::routes::support::build_page_view( + page, + request, + BagView::from_with_roast, + BAG_PAGE_PATH, + BAG_FRAGMENT_PATH, + ); + + Ok((open_bags_view, bags, navigator)) +} + +#[tracing::instrument(skip(state, cookies, headers, query))] +pub(crate) async fn bags_page( + State(state): State, + cookies: tower_cookies::Cookies, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let request = query.into_request::(); + + if is_datastar_request(&headers) { + let is_authenticated = + crate::application::routes::auth::is_authenticated(&state, &cookies).await; + return render_bag_list_fragment(state, request, is_authenticated) + .await + .map_err(map_app_error); + } + + let roasters = state + .roaster_repo + .list_all_sorted(RoasterSortKey::Name, SortDirection::Asc) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + + let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect(); + + let (open_bags, bags, navigator) = load_bag_page(&state, request) + .await + .map_err(map_app_error)?; + + let is_authenticated = + crate::application::routes::auth::is_authenticated(&state, &cookies).await; + + let template = BagsTemplate { + nav_active: "bags", + is_authenticated, + open_bags, + bags, + roaster_options, + navigator, + }; + + render_html(template).map(IntoResponse::into_response) +} + #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_bag( State(state): State, @@ -229,3 +307,59 @@ pub(crate) async fn finish_bag( Ok(Redirect::to(BAG_PAGE_PATH).into_response()) } } + +#[derive(Debug, Deserialize)] +pub struct BagsQuery { + pub roast_id: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct NewBagSubmission { + roast_id: RoastId, + roast_date: Option, + amount: f64, +} + +impl NewBagSubmission { + fn into_new_bag(self) -> Result { + let roast_id = self.roast_id; + if roast_id.into_inner() <= 0 { + return Err(AppError::validation("invalid roast id")); + } + + let roast_date = match self.roast_date { + Some(date_str) if !date_str.is_empty() => Some( + chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") + .map_err(|_| AppError::validation("invalid roast date format"))?, + ), + _ => None, + }; + + if self.amount <= 0.0 { + return Err(AppError::validation("amount must be positive")); + } + + Ok(NewBag { + roast_id, + roast_date, + amount: self.amount, + }) + } +} + +async fn render_bag_list_fragment( + state: AppState, + request: ListRequest, + is_authenticated: bool, +) -> Result { + let (open_bags, bags, navigator) = load_bag_page(&state, request).await?; + + let template = BagListTemplate { + is_authenticated, + open_bags, + bags, + navigator, + }; + + crate::application::routes::support::render_fragment(template, "#bag-list") +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index c179ad5..371bba1 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -63,6 +63,8 @@ pub fn app_router(state: AppState) -> axum::Router { "/roasters/:roaster_slug/roasts/:roast_slug", get(roasts::roast_page), ) + .route("/bags", get(bags::bags_page)) + .route("/bags/:id/finish", post(bags::finish_bag)) .route("/timeline", get(timeline::timeline_page)) .route("/styles.css", get(styles)) .route("/favicon.ico", get(favicon)) diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 9ca47dc..9c4daea 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -14,8 +14,10 @@ use crate::application::server::AppState; use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::RoasterSortKey; -use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster}; -use crate::presentation::web::templates::{RoastDetailTemplate, RoastListTemplate, RoastsTemplate}; +use crate::domain::roasts::{NewRoast, Roast, RoastSortKey}; +use crate::presentation::web::templates::{ + RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate, +}; use crate::presentation::web::views::{ListNavigator, Paginated, RoastView, RoasterOptionView}; const ROAST_PAGE_PATH: &str = "/roasts"; @@ -102,6 +104,17 @@ pub(crate) async fn roast_page( .await .map_err(|err| map_app_error(AppError::from(err)))?; + let bags = state + .bag_repo + .list_by_roast(roast.id) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + + let bag_views = bags + .into_iter() + .map(crate::presentation::web::views::BagView::from_with_roast) + .collect(); + let is_authenticated = crate::application::routes::auth::is_authenticated(&state, &cookies).await; @@ -109,6 +122,7 @@ pub(crate) async fn roast_page( nav_active: "roasts", is_authenticated, roast: RoastView::from_domain(roast, &roaster.name, &roaster.slug), + bags: bag_views, }; render_html(template) @@ -150,20 +164,45 @@ pub(crate) async fn create_roast( } } -#[tracing::instrument(skip(state))] +#[tracing::instrument(skip(state, headers))] pub(crate) async fn list_roasts( State(state): State, + headers: HeaderMap, Query(params): Query, -) -> Result>, ApiError> { - let roasts = match params.roaster_id { +) -> Result { + let roaster_id = match params.roaster_id.as_deref() { + Some(s) if !s.is_empty() => { + let s = s.trim(); + Some(s.parse::().map_err(|_| { + tracing::warn!("Invalid roaster_id: '{}'", s); + ApiError::from(AppError::validation(format!("Invalid roaster_id: '{}'", s))) + })?) + } + _ => None, + }; + + let roasts = match roaster_id { Some(roaster_id) => state .roast_repo .list_by_roaster(roaster_id) .await .map_err(AppError::from)?, - None => state.roast_repo.list_all().await.map_err(AppError::from)?, + None => { + if is_datastar_request(&headers) { + vec![] + } else { + state.roast_repo.list_all().await.map_err(AppError::from)? + } + } }; - Ok(Json(roasts)) + + if is_datastar_request(&headers) { + let template = RoastOptionsTemplate { roasts }; + crate::application::routes::support::render_fragment(template, "#roast-select-options") + .map_err(ApiError::from) + } else { + Ok(Json(roasts).into_response()) + } } #[tracing::instrument(skip(state))] @@ -197,7 +236,7 @@ pub(crate) async fn delete_roast( #[derive(Debug, Deserialize)] pub struct RoastsQuery { - pub roaster_id: Option, + pub roaster_id: Option, } #[derive(Debug, Deserialize)] diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 986a2b2..1aa6f52 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -1,11 +1,12 @@ use askama::Template; use super::views::{ - ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView, - TimelineMonthView, + BagView, ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, + TimelineEventView, TimelineMonthView, }; +use crate::domain::bags::BagSortKey; use crate::domain::roasters::RoasterSortKey; -use crate::domain::roasts::RoastSortKey; +use crate::domain::roasts::{RoastSortKey, RoastWithRoaster}; use crate::domain::timeline::TimelineSortKey; #[derive(Template)] @@ -50,6 +51,7 @@ pub struct RoastDetailTemplate { pub nav_active: &'static str, pub is_authenticated: bool, pub roast: RoastView, + pub bags: Vec, } #[derive(Template)] @@ -78,6 +80,32 @@ pub struct TimelineChunkTemplate { pub months: Vec, } +#[derive(Template)] +#[template(path = "bags.html")] +pub struct BagsTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub open_bags: Vec, + pub bags: Paginated, + pub roaster_options: Vec, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "partials/bag_list.html")] +pub struct BagListTemplate { + pub is_authenticated: bool, + pub open_bags: Vec, + pub bags: Paginated, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "partials/roast_options.html")] +pub struct RoastOptionsTemplate { + pub roasts: Vec, +} + pub fn render_template(template: T) -> Result { template.render() } diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs index 33e8634..cfb1c4f 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -1,3 +1,4 @@ +use crate::domain::bags::{Bag, BagWithRoast}; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; use crate::domain::roasters::Roaster; use crate::domain::roasts::{Roast, RoastWithRoaster}; @@ -417,10 +418,6 @@ pub struct TimelineEventDetailView { pub struct TimelineEventView { pub id: String, pub kind_label: &'static str, - pub badge_class: &'static str, - pub accent_class: &'static str, - pub card_border_class: &'static str, - pub title_class: &'static str, pub date_label: String, pub time_label: Option, pub iso_timestamp: String, @@ -454,6 +451,13 @@ impl TimelineEventView { let kind_label = match entity_type.as_str() { "roaster" => "Roaster Added", "roast" => "Roast Added", + "bag" => { + if title.starts_with("Finished") { + "Bag Finished" + } else { + "Bag Added" + } + } _ => "Event", }; @@ -462,6 +466,9 @@ impl TimelineEventView { ("roast", Some(slug), Some(roaster_slug)) => { format!("/roasters/{roaster_slug}/roasts/{slug}") } + ("bag", Some(slug), Some(roaster_slug)) => { + format!("/roasters/{roaster_slug}/roasts/{slug}") + } ("roaster", None, _) => format!("/roasters/{entity_id}"), ("roast", None, _) => format!("/roasts/{entity_id}"), _ => String::from("#"), @@ -501,10 +508,6 @@ impl TimelineEventView { Self { id: id.to_string(), kind_label, - badge_class: "bg-amber-200 text-amber-800", - accent_class: "bg-amber-600", - card_border_class: "border-amber-200 bg-amber-50/80", - title_class: "text-amber-800", date_label: occurred_at.format("%B %d, %Y").to_string(), time_label: Some(occurred_at.format("%H:%M UTC").to_string()), iso_timestamp: occurred_at.to_rfc3339(), @@ -516,3 +519,68 @@ impl TimelineEventView { } } } + +#[derive(Debug, Clone)] +pub struct BagView { + pub id: String, + pub roast_id: String, + pub roast_date: Option, + pub amount: String, + pub remaining: String, + pub closed: bool, + pub finished_at: String, + pub created_at: String, + pub roast_name: String, + pub roaster_name: String, + pub roast_slug: String, + pub roaster_slug: String, +} + +impl BagView { + pub fn from_domain( + bag: Bag, + roast_name: &str, + roaster_name: &str, + roast_slug: &str, + roaster_slug: &str, + ) -> Self { + Self { + id: bag.id.to_string(), + roast_id: bag.roast_id.to_string(), + roast_date: bag.roast_date.map(|d| d.to_string()), + amount: format!("{:.1}", bag.amount), + remaining: format!("{:.1}", bag.remaining), + closed: bag.closed, + finished_at: bag + .finished_at + .map(|d| d.to_string()) + .unwrap_or_else(|| "—".to_string()), + created_at: bag.created_at.format("%Y-%m-%d").to_string(), + roast_name: roast_name.to_string(), + roaster_name: roaster_name.to_string(), + roast_slug: roast_slug.to_string(), + roaster_slug: roaster_slug.to_string(), + } + } + + pub fn from_with_roast(bag: BagWithRoast) -> Self { + Self { + id: bag.bag.id.to_string(), + roast_id: bag.bag.roast_id.to_string(), + roast_date: bag.bag.roast_date.map(|d| d.to_string()), + amount: format!("{:.1}", bag.bag.amount), + remaining: format!("{:.1}", bag.bag.remaining), + closed: bag.bag.closed, + finished_at: bag + .bag + .finished_at + .map(|d| d.to_string()) + .unwrap_or_else(|| "—".to_string()), + created_at: bag.bag.created_at.format("%Y-%m-%d").to_string(), + roast_name: bag.roast_name, + roaster_name: bag.roaster_name, + roast_slug: bag.roast_slug, + roaster_slug: bag.roaster_slug, + } + } +} diff --git a/templates/bags.html b/templates/bags.html new file mode 100644 index 0000000..ad78565 --- /dev/null +++ b/templates/bags.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} {% block title %}Brewlog · Bags{% endblock %} {% block content %} +
+
+
+

Bags

+

Track your coffee bags.

+
+ {% if is_authenticated && !roaster_options.is_empty() %} + + {% endif %} +
+ + {% if is_authenticated %} {% if roaster_options.is_empty() %} +
+

Add a roaster first

+

+ Bags need a roaster and a roast. + Create a roaster + to enable this form. +

+
+ {% else %} + + {% endif %} {% endif %} + +
{% include "partials/bag_list.html" %}
+
+{% endblock %} diff --git a/templates/nav.html b/templates/nav.html index 1790aed..373821e 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -1,8 +1,9 @@