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:
parent
bda59f1c2a
commit
b12ad2bf6d
7 changed files with 159 additions and 17 deletions
|
|
@ -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("<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) {
|
||||
let target = ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ pub(crate) async fn timeline_page(
|
|||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
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) {
|
||||
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<TimelineSortKey>,
|
||||
is_authenticated: bool,
|
||||
) -> Result<Response, AppError> {
|
||||
let data = load_timeline_page(&state, request).await?;
|
||||
let template = TimelineChunkTemplate {
|
||||
is_authenticated,
|
||||
events: data.events,
|
||||
navigator: data.navigator,
|
||||
months: data.months,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub slug: Option<String>,
|
||||
pub roaster_slug: Option<String>,
|
||||
pub brew_data: Option<TimelineBrewData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -71,13 +71,22 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
|
|||
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
|
||||
WHEN t.entity_type = 'brew' THEN brew_roast.slug
|
||||
ELSE NULL
|
||||
END as slug,
|
||||
CASE
|
||||
WHEN t.entity_type = 'roast' THEN rst_r.slug
|
||||
WHEN t.entity_type = 'bag' THEN b_rr.slug
|
||||
WHEN t.entity_type = 'brew' THEN brew_roaster.slug
|
||||
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
|
||||
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<String>,
|
||||
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 {
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TimelineEventView>,
|
||||
pub navigator: ListNavigator<TimelineSortKey>,
|
||||
pub months: Vec<TimelineMonthView>,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub details: Vec<TimelineEventDetailView>,
|
||||
pub tasting_notes: Option<Vec<String>>,
|
||||
pub brew_data: Option<TimelineBrewDataView>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,36 @@
|
|||
<span class="sr-only">Open external link</span>
|
||||
</a>
|
||||
{% 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>
|
||||
{% if event.details.len() > 0 %}
|
||||
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
|
||||
|
|
|
|||
Loading…
Reference in a new issue