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;
|
let _ = state.timeline_repo.insert(event).await;
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
|
// 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)
|
render_brew_list_fragment(state, request, true)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)
|
.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())
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue