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 %}