feat(brews): add quick notes

Predefined toggleable tags for brews: Good, Too Fast, Too Slow,
Too Hot, Under Extracted, Over Extracted.

- Add quick_notes TEXT column storing JSON array
- Add QuickNote enum with label/form-value/positive helpers
- Update repository to encode/decode JSON, backup to export notes
- Add toggle pill UI on brew form with Datastar signals
- Show pills on home page cards (muted for Good, amber for others)
- Show comma-joined text in data table Notes column
- Add --quick-notes flag to CLI, update HTTP client
- Update bootstrap script with sample quick notes
This commit is contained in:
Jon Seager 2026-02-05 20:42:03 +00:00
parent 3bc6fae656
commit 010b700595
No known key found for this signature in database
18 changed files with 286 additions and 17 deletions

View file

@ -25,6 +25,10 @@ fn tailwind() {
println!("cargo:rerun-if-changed={entry}");
}
for entry in walkdir("static") {
// Skip the generated output file to avoid circular rebuilds
if entry == "static/css/styles.css" {
continue;
}
println!("cargo:rerun-if-changed={entry}");
}

View file

@ -0,0 +1 @@
ALTER TABLE brews ADD COLUMN quick_notes TEXT;

View file

@ -754,7 +754,8 @@ fi
--brewer-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="V60 02") | .id')" \
--filter-paper-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="V60 Tabbed 02") | .id')" \
--water-volume 250 \
--water-temp 92.0
--water-temp 92.0 \
--quick-notes good
# AeroPress brew - Finca Tamana with J-Max (ratio 1:15)
./target/debug/brewlog brew add \
@ -786,7 +787,8 @@ fi
--brewer-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="Stagg XF") | .id')" \
--filter-paper-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="Stagg XF Filters") | .id')" \
--water-volume 300 \
--water-temp 93.0
--water-temp 93.0 \
--quick-notes too-fast,under-extracted
# Light V60 brew - Red Brick Espresso with Comandante (ratio 1:16.7)
./target/debug/brewlog brew add \
@ -807,7 +809,8 @@ fi
--grind-setting 18.0 \
--brewer-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="Original") | .id')" \
--water-volume 225 \
--water-temp 85.0
--water-temp 85.0 \
--quick-notes too-hot,over-extracted
# V60 brew - Suke Quto with J-Max (ratio 1:16)
./target/debug/brewlog brew add \
@ -818,7 +821,8 @@ fi
--brewer-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="V60 02") | .id')" \
--filter-paper-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="FAST Specialty 02") | .id')" \
--water-volume 320 \
--water-temp 92.0
--water-temp 92.0 \
--quick-notes good
# Stagg XF brew - Simbi with Comandante (ratio 1:16)
./target/debug/brewlog brew add \

View file

@ -13,7 +13,7 @@ use crate::application::routes::support::{
};
use crate::application::server::AppState;
use crate::domain::bags::BagFilter;
use crate::domain::brews::{BrewFilter, BrewSortKey, BrewWithDetails, NewBrew};
use crate::domain::brews::{BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote};
use crate::domain::gear::{GearCategory, GearFilter, GearSortKey};
use crate::domain::ids::{BagId, BrewId, GearId};
use crate::domain::listing::{ListRequest, PageSize, SortDirection};
@ -21,6 +21,7 @@ use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventD
use crate::presentation::web::templates::BrewListTemplate;
use crate::presentation::web::views::{
BagOptionView, BrewDefaultsView, BrewView, GearOptionView, ListNavigator, Paginated,
QuickNoteView,
};
const BREW_PAGE_PATH: &str = "/data?type=brews";
@ -37,6 +38,7 @@ pub(crate) struct BrewFormData {
pub(crate) brewer_options: Vec<GearOptionView>,
pub(crate) filter_paper_options: Vec<GearOptionView>,
pub(crate) defaults: BrewDefaultsView,
pub(crate) quick_note_options: Vec<QuickNoteView>,
}
pub(crate) async fn load_brew_form_data(state: &AppState) -> Result<BrewFormData, AppError> {
@ -80,12 +82,19 @@ pub(crate) async fn load_brew_form_data(state: &AppState) -> Result<BrewFormData
.map(|b| BrewDefaultsView::from(b.brew))
.unwrap_or_default();
let quick_note_options = QuickNote::all()
.iter()
.copied()
.map(QuickNoteView::from)
.collect();
Ok(BrewFormData {
bag_options,
grinder_options,
brewer_options,
filter_paper_options,
defaults,
quick_note_options,
})
}
@ -147,6 +156,26 @@ where
}
}
fn deserialize_quick_notes<'de, D>(deserializer: D) -> Result<Vec<QuickNote>, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Null) => Ok(Vec::new()),
Some(serde_json::Value::String(s)) if s.is_empty() => Ok(Vec::new()),
Some(serde_json::Value::String(s)) => Ok(s
.split(',')
.filter_map(|v| QuickNote::from_str_value(v.trim()))
.collect()),
Some(serde_json::Value::Array(arr)) => Ok(arr
.iter()
.filter_map(|v| v.as_str().and_then(QuickNote::from_str_value))
.collect()),
Some(_) => Err(serde::de::Error::custom("invalid quick_notes")),
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct NewBrewSubmission {
bag_id: BagId,
@ -158,6 +187,8 @@ pub(crate) struct NewBrewSubmission {
filter_paper_id: Option<GearId>,
water_volume: i32,
water_temp: f64,
#[serde(default, deserialize_with = "deserialize_quick_notes")]
quick_notes: Vec<QuickNote>,
}
impl NewBrewSubmission {
@ -186,6 +217,7 @@ impl NewBrewSubmission {
filter_paper_id: self.filter_paper_id,
water_volume: self.water_volume,
water_temp: self.water_temp,
quick_notes: self.quick_notes,
})
}
}
@ -309,6 +341,19 @@ fn brew_timeline_event(enriched: &BrewWithDetails) -> NewTimelineEvent {
value: ratio,
});
if !enriched.brew.quick_notes.is_empty() {
let labels: Vec<&str> = enriched
.brew
.quick_notes
.iter()
.map(|n| n.label())
.collect();
details.push(TimelineEventDetail {
label: "Notes".to_string(),
value: labels.join(", "),
});
}
NewTimelineEvent {
entity_type: "brew".to_string(),
entity_id: enriched.brew.id.into_inner(),

View file

@ -99,6 +99,7 @@ pub(crate) async fn add_page(
filter_paper_options: brew_form.filter_paper_options,
cafe_options,
defaults: brew_form.defaults,
quick_note_options: brew_form.quick_note_options,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -4,6 +4,67 @@ use serde::{Deserialize, Serialize};
use super::ids::{BagId, BrewId, GearId};
use super::listing::{SortDirection, SortKey};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum QuickNote {
Good,
TooFast,
TooSlow,
TooHot,
UnderExtracted,
OverExtracted,
}
impl QuickNote {
pub fn label(self) -> &'static str {
match self {
Self::Good => "Good",
Self::TooFast => "Too Fast",
Self::TooSlow => "Too Slow",
Self::TooHot => "Too Hot",
Self::UnderExtracted => "Under Extracted",
Self::OverExtracted => "Over Extracted",
}
}
pub fn form_value(self) -> &'static str {
match self {
Self::Good => "good",
Self::TooFast => "too-fast",
Self::TooSlow => "too-slow",
Self::TooHot => "too-hot",
Self::UnderExtracted => "under-extracted",
Self::OverExtracted => "over-extracted",
}
}
pub fn from_str_value(s: &str) -> Option<Self> {
match s {
"good" | "Good" => Some(Self::Good),
"too-fast" | "Too Fast" => Some(Self::TooFast),
"too-slow" | "Too Slow" => Some(Self::TooSlow),
"too-hot" | "Too Hot" => Some(Self::TooHot),
"under-extracted" | "Under Extracted" => Some(Self::UnderExtracted),
"over-extracted" | "Over Extracted" => Some(Self::OverExtracted),
_ => None,
}
}
pub fn is_positive(self) -> bool {
matches!(self, Self::Good)
}
pub fn all() -> &'static [Self] {
&[
Self::Good,
Self::TooFast,
Self::TooSlow,
Self::TooHot,
Self::UnderExtracted,
Self::OverExtracted,
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Brew {
pub id: BrewId,
@ -15,6 +76,7 @@ pub struct Brew {
pub filter_paper_id: Option<GearId>,
pub water_volume: i32,
pub water_temp: f64,
pub quick_notes: Vec<QuickNote>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@ -42,6 +104,7 @@ pub struct NewBrew {
pub filter_paper_id: Option<GearId>,
pub water_volume: i32,
pub water_temp: f64,
pub quick_notes: Vec<QuickNote>,
}
/// Filter criteria for brew queries.

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_string};
use crate::domain::bags::Bag;
use crate::domain::brews::Brew;
use crate::domain::brews::{Brew, QuickNote};
use crate::domain::cafes::Cafe;
use crate::domain::gear::{Gear, GearCategory};
use crate::domain::ids::{BagId, BrewId, CafeId, GearId, RoastId, RoasterId, TimelineEventId};
@ -164,7 +164,7 @@ impl BackupService {
async fn export_brews(&self) -> anyhow::Result<Vec<Brew>> {
let records = sqlx::query_as::<_, BrewRecord>(
"SELECT id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, created_at, updated_at FROM brews ORDER BY id",
"SELECT id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at FROM brews ORDER BY id",
)
.fetch_all(&self.pool)
.await
@ -561,12 +561,21 @@ struct BrewRecord {
filter_paper_id: Option<i64>,
water_volume: i32,
water_temp: f64,
quick_notes: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl BrewRecord {
fn into_domain(self) -> Brew {
let quick_notes = match self.quick_notes {
Some(s) if !s.is_empty() => serde_json::from_str::<Vec<String>>(&s)
.unwrap_or_default()
.iter()
.filter_map(|v| QuickNote::from_str_value(v))
.collect(),
_ => Vec::new(),
};
Brew {
id: BrewId::new(self.id),
bag_id: BagId::new(self.bag_id),
@ -577,6 +586,7 @@ impl BrewRecord {
filter_paper_id: self.filter_paper_id.map(GearId::new),
water_volume: self.water_volume,
water_temp: self.water_temp,
quick_notes,
created_at: self.created_at,
updated_at: self.updated_at,
}

View file

@ -1,6 +1,6 @@
use anyhow::{Context, Result};
use crate::domain::brews::BrewWithDetails;
use crate::domain::brews::{BrewWithDetails, QuickNote};
use crate::domain::ids::{BagId, BrewId, GearId};
use super::BrewlogClient;
@ -25,6 +25,7 @@ impl<'a> BrewsClient<'a> {
filter_paper_id: Option<GearId>,
water_volume: i32,
water_temp: f64,
quick_notes: Vec<QuickNote>,
) -> Result<BrewWithDetails> {
let url = self.inner.endpoint("api/v1/brews")?;
let mut payload = serde_json::json!({
@ -39,6 +40,10 @@ impl<'a> BrewsClient<'a> {
if let Some(fp_id) = filter_paper_id {
payload["filter_paper_id"] = serde_json::json!(fp_id);
}
if !quick_notes.is_empty() {
let labels: Vec<&str> = quick_notes.iter().map(|n| n.label()).collect();
payload["quick_notes"] = serde_json::json!(labels);
}
let response = self
.inner

View file

@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
use sqlx::query_as;
use crate::domain::RepositoryError;
use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew};
use crate::domain::brews::{Brew, BrewFilter, BrewSortKey, BrewWithDetails, NewBrew, QuickNote};
use crate::domain::ids::{BagId, BrewId, GearId};
use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::BrewRepository;
@ -13,6 +13,7 @@ const BASE_SELECT: &str = r"
SELECT
br.id, br.bag_id, br.coffee_weight, br.grinder_id, br.grind_setting,
br.brewer_id, br.filter_paper_id, br.water_volume, br.water_temp,
br.quick_notes,
br.created_at, br.updated_at,
r.name as roast_name, r.slug as roast_slug,
rr.name as roaster_name, rr.slug as roaster_slug,
@ -51,6 +52,26 @@ impl SqlBrewRepository {
}
}
fn decode_quick_notes(raw: Option<String>) -> Vec<QuickNote> {
match raw {
Some(s) if !s.is_empty() => serde_json::from_str::<Vec<String>>(&s)
.unwrap_or_default()
.iter()
.filter_map(|v| QuickNote::from_str_value(v))
.collect(),
_ => Vec::new(),
}
}
fn encode_quick_notes(notes: &[QuickNote]) -> Option<String> {
if notes.is_empty() {
None
} else {
let labels: Vec<&str> = notes.iter().map(|n| n.label()).collect();
serde_json::to_string(&labels).ok()
}
}
fn to_domain(record: BrewRecord) -> Brew {
Brew {
id: BrewId::new(record.id),
@ -62,6 +83,7 @@ impl SqlBrewRepository {
filter_paper_id: record.filter_paper_id.map(GearId::new),
water_volume: record.water_volume,
water_temp: record.water_temp,
quick_notes: Self::decode_quick_notes(record.quick_notes),
created_at: record.created_at,
updated_at: record.updated_at,
}
@ -79,6 +101,7 @@ impl SqlBrewRepository {
filter_paper_id: record.filter_paper_id.map(GearId::new),
water_volume: record.water_volume,
water_temp: record.water_temp,
quick_notes: Self::decode_quick_notes(record.quick_notes),
created_at: record.created_at,
updated_at: record.updated_at,
},
@ -135,9 +158,9 @@ impl BrewRepository for SqlBrewRepository {
// Insert the brew
let insert_query = r"
INSERT INTO brews (bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, created_at, updated_at
INSERT INTO brews (bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at
";
let record = query_as::<_, BrewRecord>(insert_query)
@ -152,6 +175,7 @@ impl BrewRepository for SqlBrewRepository {
)
.bind(brew.water_volume)
.bind(brew.water_temp)
.bind(Self::encode_quick_notes(&brew.quick_notes))
.fetch_one(&mut *tx)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
@ -165,7 +189,7 @@ impl BrewRepository for SqlBrewRepository {
async fn get(&self, id: BrewId) -> Result<Brew, RepositoryError> {
let query = r"
SELECT id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, created_at, updated_at
SELECT id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at
FROM brews
WHERE id = ?
";
@ -263,6 +287,7 @@ struct BrewRecord {
filter_paper_id: Option<i64>,
water_volume: i32,
water_temp: f64,
quick_notes: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
@ -278,6 +303,7 @@ struct BrewWithDetailsRecord {
filter_paper_id: Option<i64>,
water_volume: i32,
water_temp: f64,
quick_notes: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
roast_name: String,

View file

@ -3,6 +3,7 @@ use clap::{Args, Subcommand};
use super::macros::{define_delete_command, define_get_command};
use super::print_json;
use crate::domain::brews::QuickNote;
use crate::domain::ids::{BagId, BrewId, GearId};
use crate::infrastructure::client::BrewlogClient;
@ -60,9 +61,19 @@ pub struct AddBrewCommand {
/// Water temperature in Celsius
#[arg(long, default_value = "91.0")]
pub water_temp: f64,
/// Quick notes (comma-separated: good,too-fast,too-slow,too-hot,under-extracted,over-extracted)
#[arg(long, value_delimiter = ',')]
pub quick_notes: Vec<String>,
}
pub async fn add_brew(client: &BrewlogClient, command: AddBrewCommand) -> Result<()> {
let quick_notes: Vec<QuickNote> = command
.quick_notes
.iter()
.filter_map(|s| QuickNote::from_str_value(s))
.collect();
let brew = client
.brews()
.create(
@ -74,6 +85,7 @@ pub async fn add_brew(client: &BrewlogClient, command: AddBrewCommand) -> Result
command.filter_paper_id.map(GearId::new),
command.water_volume,
command.water_temp,
quick_notes,
)
.await?;
print_json(&brew)

View file

@ -2,8 +2,9 @@ use askama::Template;
use super::views::{
BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView,
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, RoastOptionView, RoastView,
RoasterOptionView, RoasterView, StatsView, TimelineEventView, TimelineMonthView,
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView,
RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatsView, TimelineEventView,
TimelineMonthView,
};
use crate::domain::bags::BagSortKey;
use crate::domain::brews::BrewSortKey;
@ -170,6 +171,7 @@ pub struct AddTemplate {
pub filter_paper_options: Vec<GearOptionView>,
pub cafe_options: Vec<CafeOptionView>,
pub defaults: BrewDefaultsView,
pub quick_note_options: Vec<QuickNoteView>,
}
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {

View file

@ -1,7 +1,28 @@
use crate::domain::brews::{Brew, BrewWithDetails};
use crate::domain::brews::{Brew, BrewWithDetails, QuickNote};
use super::relative_date;
#[derive(Clone)]
pub struct QuickNoteView {
pub label: String,
pub pill_class: &'static str,
pub form_value: String,
}
impl From<QuickNote> for QuickNoteView {
fn from(note: QuickNote) -> Self {
Self {
label: note.label().to_string(),
pill_class: if note.is_positive() {
"pill pill-success"
} else {
"pill pill-warning"
},
form_value: note.form_value().to_string(),
}
}
}
#[derive(Clone)]
pub struct BrewView {
pub id: String,
@ -21,6 +42,8 @@ pub struct BrewView {
pub water_volume: String,
pub water_temp: String,
pub ratio: String,
pub quick_notes: Vec<QuickNoteView>,
pub quick_notes_label: String,
pub created_date: String,
pub created_time: String,
pub relative_date_label: String,
@ -42,6 +65,19 @@ impl BrewView {
"\u{2014}".to_string()
};
let quick_notes: Vec<QuickNoteView> = brew
.brew
.quick_notes
.iter()
.copied()
.map(QuickNoteView::from)
.collect();
let quick_notes_label = quick_notes
.iter()
.map(|n| n.label.as_str())
.collect::<Vec<_>>()
.join(", ");
Self {
id: brew.brew.id.to_string(),
bag_id: brew.brew.bag_id.into_inner(),
@ -63,6 +99,8 @@ impl BrewView {
water_volume: format!("{}ml", brew.brew.water_volume),
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
ratio,
quick_notes,
quick_notes_label,
created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(),
created_time: brew.brew.created_at.format("%H:%M").to_string(),
relative_date_label: relative_date(brew.brew.created_at),

View file

@ -8,7 +8,7 @@ mod roasts;
mod timeline;
pub use bags::{BagOptionView, BagView};
pub use brews::{BrewDefaultsView, BrewView};
pub use brews::{BrewDefaultsView, BrewView, QuickNoteView};
pub use cafes::{CafeOptionView, CafeView, NearbyCafeView};
pub use cups::CupView;
pub use gear::{GearOptionView, GearView};

View file

@ -81,6 +81,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"
>
<h2 class="text-lg font-semibold text-stone-800 mb-3">Manual Add</h2>
@ -341,6 +347,32 @@
</div>
</div>
</div>
<!-- Quick Notes -->
<div>
<h4 class="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-3">Quick Notes</h4>
<div class="flex flex-wrap gap-2">
<button type="button"
data-on:click="$_qnGood = !$_qnGood"
data-attr:class="$_qnGood ? 'pill pill-success cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'">Good</button>
<button type="button"
data-on:click="$_qnTooFast = !$_qnTooFast"
data-attr:class="$_qnTooFast ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'">Too Fast</button>
<button type="button"
data-on:click="$_qnTooSlow = !$_qnTooSlow"
data-attr:class="$_qnTooSlow ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'">Too Slow</button>
<button type="button"
data-on:click="$_qnTooHot = !$_qnTooHot"
data-attr:class="$_qnTooHot ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'">Too Hot</button>
<button type="button"
data-on:click="$_qnUnderExtracted = !$_qnUnderExtracted"
data-attr:class="$_qnUnderExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'">Under Extracted</button>
<button type="button"
data-on:click="$_qnOverExtracted = !$_qnOverExtracted"
data-attr:class="$_qnOverExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'">Over Extracted</button>
</div>
<input type="hidden" name="quick_notes"
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')" />
</div>
<div class="flex items-center justify-end">
<button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Log Brew

View file

@ -94,6 +94,13 @@
<p class="truncate"><span class="font-medium text-stone-800">{{ brew.grind_setting }}</span> <span class="text-stone-500">· {{ brew.grinder_name }}</span></p>
<p class="truncate"><span class="font-medium text-stone-800">{{ brew.brewer_name }}</span>{% if let Some(fp) = brew.filter_paper_name %} <span class="text-stone-500">· {{ fp }}</span>{% endif %}</p>
</div>
{% if !brew.quick_notes.is_empty() %}
<div class="mt-2 flex flex-wrap gap-1">
{% for qn in brew.quick_notes %}
<span class="{{ qn.pill_class }}">{{ qn.label }}</span>
{% endfor %}
</div>
{% endif %}
{% if is_authenticated %}
<div class="mt-3 pt-3 border-t flex gap-3">
<form

View file

@ -29,6 +29,7 @@
<th scope="col" class="px-4 py-3">Grind</th>
<th scope="col" class="px-4 py-3">Recipe</th>
<th scope="col" class="px-4 py-3">Brewer</th>
<th scope="col" class="px-4 py-3">Notes</th>
{% if is_authenticated %}
<th scope="col" class="px-4 py-3 text-right">Actions</th>
{% endif %}
@ -66,6 +67,13 @@
{% if let Some(fp_name) = brew.filter_paper_name %}
<td data-label="Filter" class="px-4 py-3 whitespace-nowrap md:hidden">{{ fp_name }}</td>
{% endif %}
<td data-label="Notes" class="px-4 py-3 text-xs text-stone-500">
{% if !brew.quick_notes_label.is_empty() %}
{{ brew.quick_notes_label }}
{% else %}
<span class="text-stone-400">&mdash;</span>
{% endif %}
</td>
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
<div class="flex items-center gap-4">

View file

@ -201,6 +201,7 @@ async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Ge
filter_paper_id: Some(filter_paper.id),
water_volume: 250,
water_temp: 93.5,
quick_notes: Vec::new(),
})
.await
.expect("failed to create brew");

View file

@ -25,6 +25,7 @@ async fn creating_a_brew_returns_201_for_valid_data() {
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
};
// Act
@ -71,6 +72,7 @@ async fn creating_a_brew_with_filter_paper_returns_201() {
filter_paper_id: Some(filter_paper.id),
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
};
// Act
@ -109,6 +111,7 @@ async fn creating_a_brew_deducts_from_bag_remaining() {
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
};
// Act
@ -151,6 +154,7 @@ async fn creating_a_brew_fails_if_insufficient_coffee_in_bag() {
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
};
// Act
@ -217,6 +221,7 @@ async fn listing_brews_returns_200_and_enriched_data() {
filter_paper_id: Some(filter_paper.id),
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
};
client
@ -270,6 +275,7 @@ async fn listing_brews_without_filter_paper_returns_none() {
filter_paper_id: None,
water_volume: 255,
water_temp: 88.0,
quick_notes: Vec::new(),
};
client
@ -318,6 +324,7 @@ async fn listing_brews_with_bag_filter_returns_filtered_results() {
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
};
client
@ -338,6 +345,7 @@ async fn listing_brews_with_bag_filter_returns_filtered_results() {
filter_paper_id: None,
water_volume: 255,
water_temp: 88.0,
quick_notes: Vec::new(),
};
client
@ -383,6 +391,7 @@ async fn getting_a_brew_returns_200_for_valid_id() {
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
};
let create_response = client
@ -432,6 +441,7 @@ async fn deleting_a_brew_returns_204() {
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
};
let create_response = client