feat(brews): pre-populate Add Brew form with last recorded brew values

- Add BrewDefaultsView to carry last brew's values to the template
- Extract form data loading into load_brew_form_data() helper
- Use defaults for signal initialization and dropdown pre-selection
- Retain submitted values after form submission instead of resetting
This commit is contained in:
Jon Seager 2026-02-03 14:01:41 +00:00
parent fa0cccade0
commit 09a629db9a
No known key found for this signature in database
4 changed files with 162 additions and 77 deletions

View file

@ -16,11 +16,11 @@ use crate::domain::bags::BagFilter;
use crate::domain::brews::{BrewFilter, BrewSortKey, BrewWithDetails, NewBrew}; use crate::domain::brews::{BrewFilter, BrewSortKey, BrewWithDetails, NewBrew};
use crate::domain::gear::{GearCategory, GearFilter, GearSortKey}; use crate::domain::gear::{GearCategory, GearFilter, GearSortKey};
use crate::domain::ids::{BagId, BrewId, GearId}; use crate::domain::ids::{BagId, BrewId, GearId};
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, PageSize, SortDirection};
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail}; use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
use crate::presentation::web::templates::{BrewListTemplate, BrewsTemplate}; use crate::presentation::web::templates::{BrewListTemplate, BrewsTemplate};
use crate::presentation::web::views::{ use crate::presentation::web::views::{
BagOptionView, BrewView, GearOptionView, ListNavigator, Paginated, BagOptionView, BrewDefaultsView, BrewView, GearOptionView, ListNavigator, Paginated,
}; };
const BREW_PAGE_PATH: &str = "/brews"; const BREW_PAGE_PATH: &str = "/brews";
@ -31,6 +31,104 @@ struct BrewPageData {
navigator: ListNavigator<BrewSortKey>, navigator: ListNavigator<BrewSortKey>,
} }
struct BrewFormData {
bag_options: Vec<BagOptionView>,
grinder_options: Vec<GearOptionView>,
brewer_options: Vec<GearOptionView>,
filter_paper_options: Vec<GearOptionView>,
defaults: BrewDefaultsView,
}
async fn load_brew_form_data(state: &AppState) -> Result<BrewFormData, AppError> {
let open_bags_request = ListRequest::show_all(
crate::domain::bags::BagSortKey::RoastDate,
SortDirection::Desc,
);
let open_bags = state
.bag_repo
.list(BagFilter::open(), &open_bags_request, None)
.await
.map_err(AppError::from)?;
let bag_options: Vec<BagOptionView> = open_bags
.items
.into_iter()
.map(BagOptionView::from)
.collect();
let gear_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc);
let grinders = state
.gear_repo
.list(
GearFilter::for_category(GearCategory::Grinder),
&gear_request,
None,
)
.await
.map_err(AppError::from)?;
let grinder_options: Vec<GearOptionView> = grinders
.items
.into_iter()
.map(GearOptionView::from)
.collect();
let brewers = state
.gear_repo
.list(
GearFilter::for_category(GearCategory::Brewer),
&gear_request,
None,
)
.await
.map_err(AppError::from)?;
let brewer_options: Vec<GearOptionView> = brewers
.items
.into_iter()
.map(GearOptionView::from)
.collect();
let filter_papers = state
.gear_repo
.list(
GearFilter::for_category(GearCategory::FilterPaper),
&gear_request,
None,
)
.await
.map_err(AppError::from)?;
let filter_paper_options: Vec<GearOptionView> = filter_papers
.items
.into_iter()
.map(GearOptionView::from)
.collect();
let last_brew_request = ListRequest::new(
1,
PageSize::Limited(1),
BrewSortKey::CreatedAt,
SortDirection::Desc,
);
let last_brew_page = state
.brew_repo
.list(BrewFilter::all(), &last_brew_request, None)
.await
.map_err(AppError::from)?;
let defaults = last_brew_page
.items
.into_iter()
.next()
.map(|b| BrewDefaultsView::from(b.brew))
.unwrap_or_default();
Ok(BrewFormData {
bag_options,
grinder_options,
brewer_options,
filter_paper_options,
defaults,
})
}
#[tracing::instrument(skip(state))] #[tracing::instrument(skip(state))]
async fn load_brew_page( async fn load_brew_page(
state: &AppState, state: &AppState,
@ -71,72 +169,13 @@ pub(crate) async fn brews_page(
.map_err(map_app_error); .map_err(map_app_error);
} }
// Load open bags for the form dropdown let BrewFormData {
let open_bags_request = ListRequest::show_all( bag_options,
crate::domain::bags::BagSortKey::RoastDate, grinder_options,
SortDirection::Desc, brewer_options,
); filter_paper_options,
let open_bags = state defaults,
.bag_repo } = load_brew_form_data(&state).await.map_err(map_app_error)?;
.list(BagFilter::open(), &open_bags_request, None)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let bag_options: Vec<BagOptionView> = open_bags
.items
.into_iter()
.map(BagOptionView::from)
.collect();
// Load grinders for dropdown
let grinder_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc);
let grinders = state
.gear_repo
.list(
GearFilter::for_category(GearCategory::Grinder),
&grinder_request,
None,
)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let grinder_options: Vec<GearOptionView> = grinders
.items
.into_iter()
.map(GearOptionView::from)
.collect();
// Load brewers for dropdown
let brewer_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc);
let brewers = state
.gear_repo
.list(
GearFilter::for_category(GearCategory::Brewer),
&brewer_request,
None,
)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let brewer_options: Vec<GearOptionView> = brewers
.items
.into_iter()
.map(GearOptionView::from)
.collect();
// Load filter papers for dropdown (optional gear)
let filter_paper_request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc);
let filter_papers = state
.gear_repo
.list(
GearFilter::for_category(GearCategory::FilterPaper),
&filter_paper_request,
None,
)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let filter_paper_options: Vec<GearOptionView> = filter_papers
.items
.into_iter()
.map(GearOptionView::from)
.collect();
let BrewPageData { brews, navigator } = load_brew_page(&state, request, search.as_deref()) let BrewPageData { brews, navigator } = load_brew_page(&state, request, search.as_deref())
.await .await
@ -152,6 +191,7 @@ pub(crate) async fn brews_page(
grinder_options, grinder_options,
brewer_options, brewer_options,
filter_paper_options, filter_paper_options,
defaults,
navigator, navigator,
}; };

View file

@ -1,8 +1,8 @@
use askama::Template; use askama::Template;
use super::views::{ use super::views::{
BagOptionView, BagView, BrewView, GearOptionView, GearView, ListNavigator, Paginated, BagOptionView, BagView, BrewDefaultsView, BrewView, GearOptionView, GearView, ListNavigator,
RoastView, RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView,
}; };
use crate::domain::bags::BagSortKey; use crate::domain::bags::BagSortKey;
use crate::domain::brews::BrewSortKey; use crate::domain::brews::BrewSortKey;
@ -136,6 +136,7 @@ pub struct BrewsTemplate {
pub grinder_options: Vec<GearOptionView>, pub grinder_options: Vec<GearOptionView>,
pub brewer_options: Vec<GearOptionView>, pub brewer_options: Vec<GearOptionView>,
pub filter_paper_options: Vec<GearOptionView>, pub filter_paper_options: Vec<GearOptionView>,
pub defaults: BrewDefaultsView,
pub navigator: ListNavigator<BrewSortKey>, pub navigator: ListNavigator<BrewSortKey>,
} }

View file

@ -1,5 +1,5 @@
use crate::domain::bags::BagWithRoast; use crate::domain::bags::BagWithRoast;
use crate::domain::brews::BrewWithDetails; use crate::domain::brews::{Brew, BrewWithDetails};
use crate::domain::gear::Gear; use crate::domain::gear::Gear;
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
use crate::domain::roasters::Roaster; use crate::domain::roasters::Roaster;
@ -756,3 +756,47 @@ impl From<Gear> for GearOptionView {
} }
} }
} }
pub struct BrewDefaultsView {
pub bag_id: String,
pub grinder_id: String,
pub brewer_id: String,
pub filter_paper_id: String,
pub coffee_weight: f64,
pub grind_setting: f64,
pub water_volume: i32,
pub water_temp: f64,
}
impl Default for BrewDefaultsView {
fn default() -> Self {
Self {
bag_id: String::new(),
grinder_id: String::new(),
brewer_id: String::new(),
filter_paper_id: String::new(),
coffee_weight: 15.0,
grind_setting: 6.0,
water_volume: 250,
water_temp: 91.0,
}
}
}
impl From<Brew> for BrewDefaultsView {
fn from(brew: Brew) -> Self {
Self {
bag_id: brew.bag_id.to_string(),
grinder_id: brew.grinder_id.to_string(),
brewer_id: brew.brewer_id.to_string(),
filter_paper_id: brew
.filter_paper_id
.map(|id| id.to_string())
.unwrap_or_default(),
coffee_weight: brew.coffee_weight,
grind_setting: brew.grind_setting,
water_volume: brew.water_volume,
water_temp: brew.water_temp,
}
}
}

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% block title %}Brewlog · Brews{% endblock %} {% block content %} {% extends "base.html" %} {% block title %}Brewlog · Brews{% endblock %} {% block content %}
<section data-signals:_show-form="false" data-signals:_is-submitting="false" data-signals:_temp="91.0" data-signals:_grind="6.0" data-signals:_volume="250" data-signals:_weight="15.0"> <section data-signals:_show-form="false" data-signals:_is-submitting="false" data-signals:_temp="{{ defaults.water_temp }}" data-signals:_grind="{{ defaults.grind_setting }}" data-signals:_volume="{{ defaults.water_volume }}" data-signals:_weight="{{ defaults.coffee_weight }}">
<header class="flex flex-wrap items-start justify-between gap-4"> <header class="flex flex-wrap items-start justify-between gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Brews</h1> <h1 class="text-3xl font-semibold">Brews</h1>
@ -53,7 +53,7 @@
class="mt-4 flex flex-col gap-4 overflow-hidden" class="mt-4 flex flex-col gap-4 overflow-hidden"
data-on:submit="$_isSubmitting = true; @post('/api/v1/brews?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#brew-list', mode: 'replace'}})" data-on:submit="$_isSubmitting = true; @post('/api/v1/brews?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#brew-list', mode: 'replace'}})"
data-ref="_form" data-ref="_form"
data-on:datastar-fetch="evt.detail.type === 'finished' && $_isSubmitting && ($_showForm = false, $_form && $_form.reset(), $_temp = 91.0, $_grind = 6.0, $_volume = 250, $_weight = 15.0, $_isSubmitting = false)" data-on:datastar-fetch="evt.detail.type === 'finished' && $_isSubmitting && ($_showForm = false, $_isSubmitting = false)"
> >
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Bag selector --> <!-- Bag selector -->
@ -62,7 +62,7 @@
<select name="bag_id" required class="input-field"> <select name="bag_id" required class="input-field">
<option value="">Select a bag</option> <option value="">Select a bag</option>
{% for bag in bag_options %} {% for bag in bag_options %}
<option value="{{ bag.id }}">{{ bag.label }}</option> <option value="{{ bag.id }}"{% if bag.id == defaults.bag_id %} selected{% endif %}>{{ bag.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
@ -72,7 +72,7 @@
<span class="text-stone-700">Grinder *</span> <span class="text-stone-700">Grinder *</span>
<select name="grinder_id" required class="input-field"> <select name="grinder_id" required class="input-field">
{% for grinder in grinder_options %} {% for grinder in grinder_options %}
<option value="{{ grinder.id }}">{{ grinder.label }}</option> <option value="{{ grinder.id }}"{% if grinder.id == defaults.grinder_id %} selected{% endif %}>{{ grinder.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
@ -82,7 +82,7 @@
<span class="text-stone-700">Brewer *</span> <span class="text-stone-700">Brewer *</span>
<select name="brewer_id" required class="input-field"> <select name="brewer_id" required class="input-field">
{% for brewer in brewer_options %} {% for brewer in brewer_options %}
<option value="{{ brewer.id }}">{{ brewer.label }}</option> <option value="{{ brewer.id }}"{% if brewer.id == defaults.brewer_id %} selected{% endif %}>{{ brewer.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
@ -93,7 +93,7 @@
<select name="filter_paper_id" class="input-field"> <select name="filter_paper_id" class="input-field">
<option value="">None</option> <option value="">None</option>
{% for fp in filter_paper_options %} {% for fp in filter_paper_options %}
<option value="{{ fp.id }}">{{ fp.label }}</option> <option value="{{ fp.id }}"{% if fp.id == defaults.filter_paper_id %} selected{% endif %}>{{ fp.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>