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:
Jon Seager 2026-02-07 19:57:23 +00:00
parent 51f8122201
commit df4469ff69
No known key found for this signature in database
6 changed files with 102 additions and 56 deletions

View file

@ -278,9 +278,7 @@ pub(crate) async fn create_brew(
Ok(response)
}
} else if matches!(source, PayloadSource::Form) {
let target =
ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request, search).page_href(1);
Ok(Redirect::to(&target).into_response())
Ok(Redirect::to("/").into_response())
} else {
Ok((StatusCode::CREATED, Json(enriched)).into_response())
}

View file

@ -49,6 +49,16 @@ pub(crate) struct AddQuery {
#[serde(rename = "type", default = "default_type")]
entity_type: 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 {
@ -75,6 +85,50 @@ pub(crate) async fn add_page(
)
.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 {
nav_active: "data",
is_authenticated,
@ -99,7 +153,7 @@ pub(crate) async fn add_page(
brewer_options: brew_form.brewer_options,
filter_paper_options: brew_form.filter_paper_options,
cafe_options,
defaults: brew_form.defaults,
defaults,
quick_note_options: brew_form.quick_note_options,
pre_select_bag_id: query.bag_id,
};

View file

@ -1,3 +1,5 @@
use std::fmt::Write;
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time};
use super::relative_date;
@ -126,6 +128,30 @@ impl BrewView {
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 {
@ -141,6 +167,8 @@ pub struct BrewDefaultsView {
pub water_volume: i32,
pub water_temp: f64,
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 {
@ -158,6 +186,7 @@ impl Default for BrewDefaultsView {
water_volume: 250,
water_temp: 91.0,
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_temp: brew.brew.water_temp,
brew_time: brew.brew.brew_time,
quick_notes_raw: String::new(),
}
}
}

View file

@ -89,12 +89,12 @@
data-signals:_user-lat="0"
data-signals:_user-lng="0"
data-signals:_reviewing-cafe="false"
data-signals:_qn-good="false"
data-signals:_qn-too-fast="false"
data-signals:_qn-too-slow="false"
data-signals:_qn-too-hot="false"
data-signals:_qn-under-extracted="false"
data-signals:_qn-over-extracted="false"
data-signals:_qn-good="{% if defaults.quick_notes_raw.contains("good") %}true{% else %}false{% endif %}"
data-signals:_qn-too-fast="{% if defaults.quick_notes_raw.contains("too-fast") %}true{% else %}false{% endif %}"
data-signals:_qn-too-slow="{% if defaults.quick_notes_raw.contains("too-slow") %}true{% else %}false{% endif %}"
data-signals:_qn-too-hot="{% if defaults.quick_notes_raw.contains("too-hot") %}true{% else %}false{% endif %}"
data-signals:_qn-under-extracted="{% if defaults.quick_notes_raw.contains("under-extracted") %}true{% else %}false{% endif %}"
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>

View file

@ -2,33 +2,13 @@
{% 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">
{% if is_authenticated %}
<form
class="absolute top-4 right-3"
data-on:submit="$_brewing = true; @post('/api/v1/brews', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_brewing) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_brewing = false }"
<a
href="{{ brew.brew_again_url() }}"
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"
title="Brew Again"
>
<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"
>
{{ icons::refresh("h-4.5 w-4.5") }}
</button>
</form>
{{ icons::refresh("h-4.5 w-4.5") }}
</a>
{% endif %}
<div class="h-[4.5rem] overflow-hidden pr-5">
<span class="block font-semibold text-text truncate">{{ brew.roast_name }}</span>

View file

@ -3,26 +3,10 @@
{% macro brew_actions(brew, navigator) %}
<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'}})">
<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">
{{ icons::plus_circle("h-4 w-4") }} Brew Again
</button>
</form>
<a href="{{ brew.brew_again_url() }}"
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
</a>
<button type="button"
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'}})">