feat(timeline): add "brew again" button to timeline

- Add TimelineBrewData struct to domain layer to carry raw brew
  parameters needed for repeating a brew
- Extend timeline SQL query to LEFT JOIN with brews table and fetch
  brew data (bag_id, grinder_id, brewer_id, coffee_weight, etc.)
- Add brew_data field to TimelineEventView for template access
- Add is_authenticated to TimelineChunkTemplate for auth-aware chunks
- Add "brew again" button to timeline month partial, visible only for
  authenticated users on brew events
- Server returns reload script when brew is created from timeline page,
  using Datastar's fragment patching to append script to body
This commit is contained in:
Jon Seager 2026-02-02 20:14:51 +00:00
parent bda59f1c2a
commit b12ad2bf6d
No known key found for this signature in database
7 changed files with 159 additions and 17 deletions

View file

@ -254,9 +254,28 @@ pub(crate) async fn create_brew(
let _ = state.timeline_repo.insert(event).await; let _ = state.timeline_repo.insert(event).await;
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
render_brew_list_fragment(state, request, true) // Check if request came from timeline - return a script that redirects
.await let from_timeline = headers
.map_err(ApiError::from) .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("<script>window.location.reload()</script>").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) { } else if matches!(source, PayloadSource::Form) {
let target = ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request).page_href(1); let target = ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request).page_href(1);
Ok(Redirect::to(&target).into_response()) Ok(Redirect::to(&target).into_response())

View file

@ -26,8 +26,10 @@ pub(crate) async fn timeline_page(
Query(query): Query<ListQuery>, Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let request = query.into_request_with_default::<TimelineSortKey>(TIMELINE_DEFAULT_PAGE_SIZE); let request = query.into_request_with_default::<TimelineSortKey>(TIMELINE_DEFAULT_PAGE_SIZE);
let is_authenticated = super::is_authenticated(&state, &cookies).await;
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
return render_timeline_chunk(state, request) return render_timeline_chunk(state, request, is_authenticated)
.await .await
.map_err(map_app_error); .map_err(map_app_error);
} }
@ -36,8 +38,6 @@ pub(crate) async fn timeline_page(
.await .await
.map_err(map_app_error)?; .map_err(map_app_error)?;
let is_authenticated = super::is_authenticated(&state, &cookies).await;
let template = TimelineTemplate { let template = TimelineTemplate {
nav_active: "timeline", nav_active: "timeline",
is_authenticated, is_authenticated,
@ -58,9 +58,11 @@ struct TimelinePreparedEvent {
async fn render_timeline_chunk( async fn render_timeline_chunk(
state: AppState, state: AppState,
request: ListRequest<TimelineSortKey>, request: ListRequest<TimelineSortKey>,
is_authenticated: bool,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let data = load_timeline_page(&state, request).await?; let data = load_timeline_page(&state, request).await?;
let template = TimelineChunkTemplate { let template = TimelineChunkTemplate {
is_authenticated,
events: data.events, events: data.events,
navigator: data.navigator, navigator: data.navigator,
months: data.months, months: data.months,

View file

@ -10,6 +10,18 @@ pub struct TimelineEventDetail {
pub value: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEvent { pub struct TimelineEvent {
pub id: TimelineEventId, pub id: TimelineEventId,
@ -22,6 +34,7 @@ pub struct TimelineEvent {
pub tasting_notes: Vec<String>, pub tasting_notes: Vec<String>,
pub slug: Option<String>, pub slug: Option<String>,
pub roaster_slug: Option<String>, pub roaster_slug: Option<String>,
pub brew_data: Option<TimelineBrewData>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -3,7 +3,7 @@ use crate::domain::ids::TimelineEventId;
use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::TimelineEventRepository; use crate::domain::repositories::TimelineEventRepository;
use crate::domain::timeline::{ use crate::domain::timeline::{
NewTimelineEvent, TimelineEvent, TimelineEventDetail, TimelineSortKey, NewTimelineEvent, TimelineBrewData, TimelineEvent, TimelineEventDetail, TimelineSortKey,
}; };
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
use async_trait::async_trait; use async_trait::async_trait;
@ -71,13 +71,22 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
WHEN t.entity_type = 'roaster' THEN r.slug WHEN t.entity_type = 'roaster' THEN r.slug
WHEN t.entity_type = 'roast' THEN rst.slug WHEN t.entity_type = 'roast' THEN rst.slug
WHEN t.entity_type = 'bag' THEN b_r.slug WHEN t.entity_type = 'bag' THEN b_r.slug
WHEN t.entity_type = 'brew' THEN brew_roast.slug
ELSE NULL ELSE NULL
END as slug, END as slug,
CASE CASE
WHEN t.entity_type = 'roast' THEN rst_r.slug WHEN t.entity_type = 'roast' THEN rst_r.slug
WHEN t.entity_type = 'bag' THEN b_rr.slug WHEN t.entity_type = 'bag' THEN b_rr.slug
WHEN t.entity_type = 'brew' THEN brew_roaster.slug
ELSE NULL ELSE NULL
END as roaster_slug 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 FROM timeline_events t
LEFT JOIN roasters r ON t.entity_type = 'roaster' AND t.entity_id = r.id 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 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 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 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 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"; let count_query = "SELECT COUNT(*) FROM timeline_events";
crate::infrastructure::repositories::pagination::paginate( crate::infrastructure::repositories::pagination::paginate(
@ -112,6 +125,14 @@ struct TimelineEventRecord {
tasting_notes_json: Option<String>, tasting_notes_json: Option<String>,
slug: Option<String>, slug: Option<String>,
roaster_slug: Option<String>, roaster_slug: Option<String>,
// Brew-specific fields (only populated for brew events)
brew_bag_id: Option<i64>,
brew_grinder_id: Option<i64>,
brew_brewer_id: Option<i64>,
brew_coffee_weight: Option<f64>,
brew_grind_setting: Option<f64>,
brew_water_volume: Option<i32>,
brew_water_temp: Option<f64>,
} }
impl TimelineEventRecord { impl TimelineEventRecord {
@ -136,6 +157,36 @@ impl TimelineEventRecord {
_ => Vec::new(), _ => 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 { Ok(TimelineEvent {
id: TimelineEventId::from(self.id), id: TimelineEventId::from(self.id),
entity_type: self.entity_type, entity_type: self.entity_type,
@ -147,6 +198,7 @@ impl TimelineEventRecord {
tasting_notes, tasting_notes,
slug: self.slug, slug: self.slug,
roaster_slug: self.roaster_slug, roaster_slug: self.roaster_slug,
brew_data,
}) })
} }
} }

View file

@ -77,6 +77,7 @@ pub struct TimelineTemplate {
#[derive(Template)] #[derive(Template)]
#[template(path = "partials/timeline_chunk.html")] #[template(path = "partials/timeline_chunk.html")]
pub struct TimelineChunkTemplate { pub struct TimelineChunkTemplate {
pub is_authenticated: bool,
pub events: Paginated<TimelineEventView>, pub events: Paginated<TimelineEventView>,
pub navigator: ListNavigator<TimelineSortKey>, pub navigator: ListNavigator<TimelineSortKey>,
pub months: Vec<TimelineMonthView>, pub months: Vec<TimelineMonthView>,

View file

@ -415,6 +415,18 @@ pub struct TimelineEventDetailView {
pub value: String, 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)] #[derive(Clone)]
pub struct TimelineEventView { pub struct TimelineEventView {
pub id: String, pub id: String,
@ -427,6 +439,7 @@ pub struct TimelineEventView {
pub external_link: Option<String>, pub external_link: Option<String>,
pub details: Vec<TimelineEventDetailView>, pub details: Vec<TimelineEventDetailView>,
pub tasting_notes: Option<Vec<String>>, pub tasting_notes: Option<Vec<String>>,
pub brew_data: Option<TimelineBrewDataView>,
} }
pub struct TimelineMonthView { pub struct TimelineMonthView {
@ -448,6 +461,7 @@ impl TimelineEventView {
tasting_notes, tasting_notes,
slug, slug,
roaster_slug, roaster_slug,
brew_data,
} = event; } = event;
let kind_label = match (entity_type.as_str(), action.as_str()) { let kind_label = match (entity_type.as_str(), action.as_str()) {
@ -504,6 +518,16 @@ impl TimelineEventView {
None 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 { Self {
id: id.to_string(), id: id.to_string(),
kind_label, kind_label,
@ -515,6 +539,7 @@ impl TimelineEventView {
external_link, external_link,
details: mapped_details, details: mapped_details,
tasting_notes, tasting_notes,
brew_data: brew_data_view,
} }
} }
} }

View file

@ -43,6 +43,36 @@
<span class="sr-only">Open external link</span> <span class="sr-only">Open external link</span>
</a> </a>
{% endif %} {% endif %}
{% if is_authenticated %}
{% if let Some(brew) = event.brew_data %}
<form
class="inline ml-auto"
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
>
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
<input type="hidden" name="water_volume" value="{{ brew.water_volume }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp }}" />
<button
type="submit"
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition hover:text-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
title="Brew again"
>
<span class="sr-only">Brew again</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z"
clip-rule="evenodd"
/>
</svg>
</button>
</form>
{% endif %}
{% endif %}
</h3> </h3>
{% if event.details.len() > 0 %} {% if event.details.len() > 0 %}
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600"> <dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">