feat(brews): make Brew Again navigate to pre-filled add form
Instead of immediately creating a duplicate brew via inline POST, the Brew Again button now links to the add-brew page with query params that pre-fill all fields (bag, grinder, grind setting, brewer, filter paper, water volume/temp, brew time, quick notes). This lets users review and adjust parameters before brewing.
This commit is contained in:
parent
51f8122201
commit
df4469ff69
6 changed files with 102 additions and 56 deletions
|
|
@ -278,9 +278,7 @@ pub(crate) async fn create_brew(
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
} else if matches!(source, PayloadSource::Form) {
|
} else if matches!(source, PayloadSource::Form) {
|
||||||
let target =
|
Ok(Redirect::to("/").into_response())
|
||||||
ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request, search).page_href(1);
|
|
||||||
Ok(Redirect::to(&target).into_response())
|
|
||||||
} else {
|
} else {
|
||||||
Ok((StatusCode::CREATED, Json(enriched)).into_response())
|
Ok((StatusCode::CREATED, Json(enriched)).into_response())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,16 @@ pub(crate) struct AddQuery {
|
||||||
#[serde(rename = "type", default = "default_type")]
|
#[serde(rename = "type", default = "default_type")]
|
||||||
entity_type: String,
|
entity_type: String,
|
||||||
bag_id: Option<String>,
|
bag_id: Option<String>,
|
||||||
|
// Brew-again overrides
|
||||||
|
coffee_weight: Option<f64>,
|
||||||
|
grinder_id: Option<String>,
|
||||||
|
grind_setting: Option<f64>,
|
||||||
|
brewer_id: Option<String>,
|
||||||
|
filter_paper_id: Option<String>,
|
||||||
|
water_volume: Option<i32>,
|
||||||
|
water_temp: Option<f64>,
|
||||||
|
brew_time: Option<i32>,
|
||||||
|
quick_notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_type() -> String {
|
fn default_type() -> String {
|
||||||
|
|
@ -75,6 +85,50 @@ pub(crate) async fn add_page(
|
||||||
)
|
)
|
||||||
.map_err(map_app_error)?;
|
.map_err(map_app_error)?;
|
||||||
|
|
||||||
|
let mut defaults = brew_form.defaults;
|
||||||
|
|
||||||
|
// Apply brew-again query param overrides
|
||||||
|
if let Some(cw) = query.coffee_weight {
|
||||||
|
defaults.coffee_weight = cw;
|
||||||
|
}
|
||||||
|
if let Some(ref gid) = query.grinder_id {
|
||||||
|
if let Some(opt) = brew_form.grinder_options.iter().find(|o| o.id == *gid) {
|
||||||
|
defaults.grinder_name = opt.label.clone();
|
||||||
|
}
|
||||||
|
defaults.grinder_id = gid.clone();
|
||||||
|
}
|
||||||
|
if let Some(gs) = query.grind_setting {
|
||||||
|
defaults.grind_setting = gs;
|
||||||
|
}
|
||||||
|
if let Some(ref bid) = query.brewer_id {
|
||||||
|
if let Some(opt) = brew_form.brewer_options.iter().find(|o| o.id == *bid) {
|
||||||
|
defaults.brewer_name = opt.label.clone();
|
||||||
|
}
|
||||||
|
defaults.brewer_id = bid.clone();
|
||||||
|
}
|
||||||
|
if let Some(ref fpid) = query.filter_paper_id {
|
||||||
|
if let Some(opt) = brew_form
|
||||||
|
.filter_paper_options
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.id == *fpid)
|
||||||
|
{
|
||||||
|
defaults.filter_paper_name = opt.label.clone();
|
||||||
|
}
|
||||||
|
defaults.filter_paper_id = fpid.clone();
|
||||||
|
}
|
||||||
|
if let Some(wv) = query.water_volume {
|
||||||
|
defaults.water_volume = wv;
|
||||||
|
}
|
||||||
|
if let Some(wt) = query.water_temp {
|
||||||
|
defaults.water_temp = wt;
|
||||||
|
}
|
||||||
|
if let Some(bt) = query.brew_time {
|
||||||
|
defaults.brew_time = Some(bt);
|
||||||
|
}
|
||||||
|
if let Some(ref qn) = query.quick_notes {
|
||||||
|
defaults.quick_notes_raw.clone_from(qn);
|
||||||
|
}
|
||||||
|
|
||||||
let template = AddTemplate {
|
let template = AddTemplate {
|
||||||
nav_active: "data",
|
nav_active: "data",
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
|
|
@ -99,7 +153,7 @@ pub(crate) async fn add_page(
|
||||||
brewer_options: brew_form.brewer_options,
|
brewer_options: brew_form.brewer_options,
|
||||||
filter_paper_options: brew_form.filter_paper_options,
|
filter_paper_options: brew_form.filter_paper_options,
|
||||||
cafe_options,
|
cafe_options,
|
||||||
defaults: brew_form.defaults,
|
defaults,
|
||||||
quick_note_options: brew_form.quick_note_options,
|
quick_note_options: brew_form.quick_note_options,
|
||||||
pre_select_bag_id: query.bag_id,
|
pre_select_bag_id: query.bag_id,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time};
|
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time};
|
||||||
|
|
||||||
use super::relative_date;
|
use super::relative_date;
|
||||||
|
|
@ -126,6 +128,30 @@ impl BrewView {
|
||||||
brew_time_raw: brew.brew.brew_time,
|
brew_time_raw: brew.brew.brew_time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a URL to the add-brew form pre-filled with this brew's parameters.
|
||||||
|
pub fn brew_again_url(&self) -> String {
|
||||||
|
let mut url = format!(
|
||||||
|
"/add?type=brew&bag_id={}&coffee_weight={}&grinder_id={}&grind_setting={}&brewer_id={}&water_volume={}&water_temp={}",
|
||||||
|
self.bag_id,
|
||||||
|
self.coffee_weight_raw,
|
||||||
|
self.grinder_id,
|
||||||
|
self.grind_setting_raw,
|
||||||
|
self.brewer_id,
|
||||||
|
self.water_volume_raw,
|
||||||
|
self.water_temp_raw,
|
||||||
|
);
|
||||||
|
if let Some(fp_id) = self.filter_paper_id {
|
||||||
|
let _ = write!(url, "&filter_paper_id={fp_id}");
|
||||||
|
}
|
||||||
|
if let Some(bt) = self.brew_time_raw {
|
||||||
|
let _ = write!(url, "&brew_time={bt}");
|
||||||
|
}
|
||||||
|
if !self.quick_notes_raw.is_empty() {
|
||||||
|
let _ = write!(url, "&quick_notes={}", self.quick_notes_raw);
|
||||||
|
}
|
||||||
|
url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct BrewDefaultsView {
|
pub struct BrewDefaultsView {
|
||||||
|
|
@ -141,6 +167,8 @@ pub struct BrewDefaultsView {
|
||||||
pub water_volume: i32,
|
pub water_volume: i32,
|
||||||
pub water_temp: f64,
|
pub water_temp: f64,
|
||||||
pub brew_time: Option<i32>,
|
pub brew_time: Option<i32>,
|
||||||
|
/// Comma-separated quick note form values (e.g. "good,too-fast") for pre-filling toggles.
|
||||||
|
pub quick_notes_raw: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BrewDefaultsView {
|
impl Default for BrewDefaultsView {
|
||||||
|
|
@ -158,6 +186,7 @@ impl Default for BrewDefaultsView {
|
||||||
water_volume: 250,
|
water_volume: 250,
|
||||||
water_temp: 91.0,
|
water_temp: 91.0,
|
||||||
brew_time: Some(120),
|
brew_time: Some(120),
|
||||||
|
quick_notes_raw: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +210,7 @@ impl From<BrewWithDetails> for BrewDefaultsView {
|
||||||
water_volume: brew.brew.water_volume,
|
water_volume: brew.brew.water_volume,
|
||||||
water_temp: brew.brew.water_temp,
|
water_temp: brew.brew.water_temp,
|
||||||
brew_time: brew.brew.brew_time,
|
brew_time: brew.brew.brew_time,
|
||||||
|
quick_notes_raw: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,12 @@
|
||||||
data-signals:_user-lat="0"
|
data-signals:_user-lat="0"
|
||||||
data-signals:_user-lng="0"
|
data-signals:_user-lng="0"
|
||||||
data-signals:_reviewing-cafe="false"
|
data-signals:_reviewing-cafe="false"
|
||||||
data-signals:_qn-good="false"
|
data-signals:_qn-good="{% if defaults.quick_notes_raw.contains("good") %}true{% else %}false{% endif %}"
|
||||||
data-signals:_qn-too-fast="false"
|
data-signals:_qn-too-fast="{% if defaults.quick_notes_raw.contains("too-fast") %}true{% else %}false{% endif %}"
|
||||||
data-signals:_qn-too-slow="false"
|
data-signals:_qn-too-slow="{% if defaults.quick_notes_raw.contains("too-slow") %}true{% else %}false{% endif %}"
|
||||||
data-signals:_qn-too-hot="false"
|
data-signals:_qn-too-hot="{% if defaults.quick_notes_raw.contains("too-hot") %}true{% else %}false{% endif %}"
|
||||||
data-signals:_qn-under-extracted="false"
|
data-signals:_qn-under-extracted="{% if defaults.quick_notes_raw.contains("under-extracted") %}true{% else %}false{% endif %}"
|
||||||
data-signals:_qn-over-extracted="false"
|
data-signals:_qn-over-extracted="{% if defaults.quick_notes_raw.contains("over-extracted") %}true{% else %}false{% endif %}"
|
||||||
>
|
>
|
||||||
<h2 class="text-lg font-semibold text-text mb-3">Manual Add</h2>
|
<h2 class="text-lg font-semibold text-text mb-3">Manual Add</h2>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,13 @@
|
||||||
{% macro card(brew, is_authenticated) %}
|
{% macro card(brew, is_authenticated) %}
|
||||||
<div class="relative w-[45vw] md:w-[200px] h-[280px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col">
|
<div class="relative w-[45vw] md:w-[200px] h-[280px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col">
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<form
|
<a
|
||||||
class="absolute top-4 right-3"
|
href="{{ brew.brew_again_url() }}"
|
||||||
data-on:submit="$_brewing = true; @post('/api/v1/brews', {contentType: 'form'})"
|
class="absolute top-4 right-3 inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
||||||
data-on:datastar-fetch="if (!$_brewing) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_brewing = false }"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
|
|
||||||
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
|
|
||||||
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
|
||||||
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
|
|
||||||
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
|
||||||
{% if let Some(fp_id) = brew.filter_paper_id %}
|
|
||||||
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
|
|
||||||
{% endif %}
|
|
||||||
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
|
|
||||||
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
|
|
||||||
<input type="hidden" name="quick_notes" value="{{ brew.quick_notes_raw }}" />
|
|
||||||
{% if let Some(bt) = brew.brew_time_raw %}
|
|
||||||
<input type="hidden" name="brew_time" value="{{ bt }}" />
|
|
||||||
{% endif %}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
|
|
||||||
title="Brew Again"
|
title="Brew Again"
|
||||||
>
|
>
|
||||||
{{ icons::refresh("h-4.5 w-4.5") }}
|
{{ icons::refresh("h-4.5 w-4.5") }}
|
||||||
</button>
|
</a>
|
||||||
</form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="h-[4.5rem] overflow-hidden pr-5">
|
<div class="h-[4.5rem] overflow-hidden pr-5">
|
||||||
<span class="block font-semibold text-text truncate">{{ brew.roast_name }}</span>
|
<span class="block font-semibold text-text truncate">{{ brew.roast_name }}</span>
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,10 @@
|
||||||
|
|
||||||
{% macro brew_actions(brew, navigator) %}
|
{% macro brew_actions(brew, navigator) %}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<form class="inline" data-on:submit="@post('/api/v1/brews?{{ navigator.query() }}', {contentType: 'form', responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
|
<a href="{{ brew.brew_again_url() }}"
|
||||||
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
|
|
||||||
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
|
|
||||||
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
|
|
||||||
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
|
|
||||||
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
|
|
||||||
{% if let Some(fp_id) = brew.filter_paper_id %}
|
|
||||||
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
|
|
||||||
{% endif %}
|
|
||||||
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
|
|
||||||
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
|
|
||||||
<input type="hidden" name="quick_notes" value="{{ brew.quick_notes_raw }}" />
|
|
||||||
{% if let Some(bt) = brew.brew_time_raw %}
|
|
||||||
<input type="hidden" name="brew_time" value="{{ bt }}" />
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit"
|
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover">
|
class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover">
|
||||||
{{ icons::plus_circle("h-4 w-4") }} Brew Again
|
{{ icons::plus_circle("h-4 w-4") }} Brew Again
|
||||||
</button>
|
</a>
|
||||||
</form>
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
|
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
|
||||||
data-on:click="confirm('Delete this brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
|
data-on:click="confirm('Delete this brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue