diff --git a/src/application/routes/brews.rs b/src/application/routes/brews.rs index 30cc68c..d079fbf 100644 --- a/src/application/routes/brews.rs +++ b/src/application/routes/brews.rs @@ -254,9 +254,28 @@ pub(crate) async fn create_brew( let _ = state.timeline_repo.insert(event).await; if is_datastar_request(&headers) { - render_brew_list_fragment(state, request, true) - .await - .map_err(ApiError::from) + // Check if request came from timeline - return a script that redirects + let from_timeline = headers + .get("referer") + .and_then(|v| v.to_str().ok()) + .is_some_and(|r| r.contains("/timeline")); + + if from_timeline { + use axum::http::header::HeaderValue; + let mut response = + axum::response::Html("").into_response(); + response + .headers_mut() + .insert("datastar-selector", HeaderValue::from_static("body")); + response + .headers_mut() + .insert("datastar-mode", HeaderValue::from_static("append")); + Ok(response) + } else { + render_brew_list_fragment(state, request, true) + .await + .map_err(ApiError::from) + } } else if matches!(source, PayloadSource::Form) { let target = ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request).page_href(1); Ok(Redirect::to(&target).into_response()) diff --git a/src/application/routes/timeline.rs b/src/application/routes/timeline.rs index beafc1a..e6c92af 100644 --- a/src/application/routes/timeline.rs +++ b/src/application/routes/timeline.rs @@ -26,8 +26,10 @@ pub(crate) async fn timeline_page( Query(query): Query, ) -> Result { let request = query.into_request_with_default::(TIMELINE_DEFAULT_PAGE_SIZE); + let is_authenticated = super::is_authenticated(&state, &cookies).await; + if is_datastar_request(&headers) { - return render_timeline_chunk(state, request) + return render_timeline_chunk(state, request, is_authenticated) .await .map_err(map_app_error); } @@ -36,8 +38,6 @@ pub(crate) async fn timeline_page( .await .map_err(map_app_error)?; - let is_authenticated = super::is_authenticated(&state, &cookies).await; - let template = TimelineTemplate { nav_active: "timeline", is_authenticated, @@ -58,9 +58,11 @@ struct TimelinePreparedEvent { async fn render_timeline_chunk( state: AppState, request: ListRequest, + is_authenticated: bool, ) -> Result { let data = load_timeline_page(&state, request).await?; let template = TimelineChunkTemplate { + is_authenticated, events: data.events, navigator: data.navigator, months: data.months, diff --git a/src/domain/timeline.rs b/src/domain/timeline.rs index 72b25c3..c0344a7 100644 --- a/src/domain/timeline.rs +++ b/src/domain/timeline.rs @@ -10,6 +10,18 @@ pub struct TimelineEventDetail { pub value: String, } +/// Raw brew data for repeating a brew from the timeline. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineBrewData { + pub bag_id: i64, + pub grinder_id: i64, + pub brewer_id: i64, + pub coffee_weight: f64, + pub grind_setting: f64, + pub water_volume: i32, + pub water_temp: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineEvent { pub id: TimelineEventId, @@ -22,6 +34,7 @@ pub struct TimelineEvent { pub tasting_notes: Vec, pub slug: Option, pub roaster_slug: Option, + pub brew_data: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index e51ef17..6035daa 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -3,7 +3,7 @@ use crate::domain::ids::TimelineEventId; use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::TimelineEventRepository; use crate::domain::timeline::{ - NewTimelineEvent, TimelineEvent, TimelineEventDetail, TimelineSortKey, + NewTimelineEvent, TimelineBrewData, TimelineEvent, TimelineEventDetail, TimelineSortKey, }; use crate::infrastructure::database::DatabasePool; use async_trait::async_trait; @@ -65,19 +65,28 @@ impl TimelineEventRepository for SqlTimelineEventRepository { }; let order_clause = format!("t.occurred_at {direction_sql}, t.id DESC"); - let base_query = "SELECT + let base_query = "SELECT t.id, t.entity_type, t.entity_id, t.action, t.occurred_at, t.title, t.details_json, t.tasting_notes_json, - CASE - WHEN t.entity_type = 'roaster' THEN r.slug - WHEN t.entity_type = 'roast' THEN rst.slug + CASE + WHEN t.entity_type = 'roaster' THEN r.slug + WHEN t.entity_type = 'roast' THEN rst.slug WHEN t.entity_type = 'bag' THEN b_r.slug - ELSE NULL + WHEN t.entity_type = 'brew' THEN brew_roast.slug + ELSE NULL END as slug, - CASE - WHEN t.entity_type = 'roast' THEN rst_r.slug + CASE + WHEN t.entity_type = 'roast' THEN rst_r.slug WHEN t.entity_type = 'bag' THEN b_rr.slug - ELSE NULL - END as roaster_slug + WHEN t.entity_type = 'brew' THEN brew_roaster.slug + ELSE NULL + END as roaster_slug, + brew.bag_id as brew_bag_id, + brew.grinder_id as brew_grinder_id, + brew.brewer_id as brew_brewer_id, + brew.coffee_weight as brew_coffee_weight, + brew.grind_setting as brew_grind_setting, + brew.water_volume as brew_water_volume, + brew.water_temp as brew_water_temp FROM timeline_events t LEFT JOIN roasters r ON t.entity_type = 'roaster' AND t.entity_id = r.id LEFT JOIN roasts rst ON t.entity_type = 'roast' AND t.entity_id = rst.id @@ -85,7 +94,11 @@ impl TimelineEventRepository for SqlTimelineEventRepository { LEFT JOIN bags b ON t.entity_type = 'bag' AND t.entity_id = b.id LEFT JOIN roasts b_r ON b.roast_id = b_r.id LEFT JOIN roasters b_rr ON b_r.roaster_id = b_rr.id - LEFT JOIN gear g ON t.entity_type = 'gear' AND t.entity_id = g.id"; + LEFT JOIN gear g ON t.entity_type = 'gear' AND t.entity_id = g.id + LEFT JOIN brews brew ON t.entity_type = 'brew' AND t.entity_id = brew.id + LEFT JOIN bags brew_bag ON brew.bag_id = brew_bag.id + LEFT JOIN roasts brew_roast ON brew_bag.roast_id = brew_roast.id + LEFT JOIN roasters brew_roaster ON brew_roast.roaster_id = brew_roaster.id"; let count_query = "SELECT COUNT(*) FROM timeline_events"; crate::infrastructure::repositories::pagination::paginate( @@ -112,6 +125,14 @@ struct TimelineEventRecord { tasting_notes_json: Option, slug: Option, roaster_slug: Option, + // Brew-specific fields (only populated for brew events) + brew_bag_id: Option, + brew_grinder_id: Option, + brew_brewer_id: Option, + brew_coffee_weight: Option, + brew_grind_setting: Option, + brew_water_volume: Option, + brew_water_temp: Option, } impl TimelineEventRecord { @@ -136,6 +157,36 @@ impl TimelineEventRecord { _ => Vec::new(), }; + // Build brew_data if all required fields are present + let brew_data = match ( + self.brew_bag_id, + self.brew_grinder_id, + self.brew_brewer_id, + self.brew_coffee_weight, + self.brew_grind_setting, + self.brew_water_volume, + self.brew_water_temp, + ) { + ( + Some(bag_id), + Some(grinder_id), + Some(brewer_id), + Some(coffee_weight), + Some(grind_setting), + Some(water_volume), + Some(water_temp), + ) => Some(TimelineBrewData { + bag_id, + grinder_id, + brewer_id, + coffee_weight, + grind_setting, + water_volume, + water_temp, + }), + _ => None, + }; + Ok(TimelineEvent { id: TimelineEventId::from(self.id), entity_type: self.entity_type, @@ -147,6 +198,7 @@ impl TimelineEventRecord { tasting_notes, slug: self.slug, roaster_slug: self.roaster_slug, + brew_data, }) } } diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index bd8e3a0..dd0ed84 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -77,6 +77,7 @@ pub struct TimelineTemplate { #[derive(Template)] #[template(path = "partials/timeline_chunk.html")] pub struct TimelineChunkTemplate { + pub is_authenticated: bool, pub events: Paginated, pub navigator: ListNavigator, pub months: Vec, diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs index 88fb2d1..e42f78c 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -415,6 +415,18 @@ pub struct TimelineEventDetailView { pub value: String, } +/// Raw brew data for repeating a brew from the timeline. +#[derive(Clone)] +pub struct TimelineBrewDataView { + pub bag_id: i64, + pub grinder_id: i64, + pub brewer_id: i64, + pub coffee_weight: f64, + pub grind_setting: f64, + pub water_volume: i32, + pub water_temp: f64, +} + #[derive(Clone)] pub struct TimelineEventView { pub id: String, @@ -427,6 +439,7 @@ pub struct TimelineEventView { pub external_link: Option, pub details: Vec, pub tasting_notes: Option>, + pub brew_data: Option, } pub struct TimelineMonthView { @@ -448,6 +461,7 @@ impl TimelineEventView { tasting_notes, slug, roaster_slug, + brew_data, } = event; let kind_label = match (entity_type.as_str(), action.as_str()) { @@ -504,6 +518,16 @@ impl TimelineEventView { None }; + let brew_data_view = brew_data.map(|bd| TimelineBrewDataView { + bag_id: bd.bag_id, + grinder_id: bd.grinder_id, + brewer_id: bd.brewer_id, + coffee_weight: bd.coffee_weight, + grind_setting: bd.grind_setting, + water_volume: bd.water_volume, + water_temp: bd.water_temp, + }); + Self { id: id.to_string(), kind_label, @@ -515,6 +539,7 @@ impl TimelineEventView { external_link, details: mapped_details, tasting_notes, + brew_data: brew_data_view, } } } diff --git a/templates/partials/timeline_month.html b/templates/partials/timeline_month.html index a981de3..d5da79b 100644 --- a/templates/partials/timeline_month.html +++ b/templates/partials/timeline_month.html @@ -43,6 +43,36 @@ Open external link {% endif %} + {% if is_authenticated %} + {% if let Some(brew) = event.brew_data %} +
+ + + + + + + + +
+ {% endif %} + {% endif %} {% if event.details.len() > 0 %}