diff --git a/migrations/0003_activities.sql b/migrations/0003_activities.sql index 8e497cf..8a85796 100644 --- a/migrations/0003_activities.sql +++ b/migrations/0003_activities.sql @@ -11,6 +11,7 @@ CREATE TABLE brews ( water_volume INTEGER NOT NULL, water_temp REAL NOT NULL, quick_notes TEXT, + brew_time INTEGER, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); diff --git a/scripts/bootstrap-db.sh b/scripts/bootstrap-db.sh index 73dc051..9357a88 100755 --- a/scripts/bootstrap-db.sh +++ b/scripts/bootstrap-db.sh @@ -855,6 +855,7 @@ fi --water-volume 250 \ --water-temp 92.0 \ --quick-notes good \ + --brew-time 135 \ --created-at "2025-11-18T08:30:00Z" # AeroPress brew - Finca Tamana with J-Max (ratio 1:15) — late Nov @@ -866,6 +867,7 @@ fi --brewer-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="Original") | .id')" \ --water-volume 255 \ --water-temp 88.0 \ + --brew-time 105 \ --created-at "2025-11-25T09:00:00Z" # Double V60 brew - La Linda with Comandante (ratio 1:16.7) — mid Dec @@ -878,6 +880,7 @@ fi --filter-paper-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="FAST Specialty 02") | .id')" \ --water-volume 500 \ --water-temp 91.0 \ + --brew-time 165 \ --created-at "2025-12-15T10:00:00Z" # Stagg XF brew - Guji Highland with J-Max (ratio 1:16.7) — late Dec @@ -891,6 +894,7 @@ fi --water-volume 300 \ --water-temp 93.0 \ --quick-notes too-fast,under-extracted \ + --brew-time 110 \ --created-at "2025-12-22T08:00:00Z" # Light V60 brew - Red Brick Espresso with Comandante (ratio 1:16.7) — early Jan @@ -903,6 +907,7 @@ fi --filter-paper-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="V60 Tabbed 02") | .id')" \ --water-volume 200 \ --water-temp 94.0 \ + --brew-time 150 \ --created-at "2026-01-02T09:30:00Z" # AeroPress inverted - El Paraiso Anaerobic with J-Max (ratio 1:15) — mid Jan @@ -915,6 +920,7 @@ fi --water-volume 225 \ --water-temp 85.0 \ --quick-notes too-hot,over-extracted \ + --brew-time 120 \ --created-at "2026-01-12T08:15:00Z" # V60 brew - Suke Quto with J-Max (ratio 1:16) — late Jan @@ -928,6 +934,7 @@ fi --water-volume 320 \ --water-temp 92.0 \ --quick-notes good \ + --brew-time 140 \ --created-at "2026-01-20T09:00:00Z" # Stagg XF brew - Simbi with Comandante (ratio 1:16) — late Jan @@ -940,6 +947,7 @@ fi --filter-paper-id "$(./target/debug/brewlog gear list | jq -r '.[] | select(.model=="Stagg XF Filters") | .id')" \ --water-volume 256 \ --water-temp 90.0 \ + --brew-time 130 \ --created-at "2026-01-28T08:45:00Z" # ============================================================================ diff --git a/src/application/routes/api/brews.rs b/src/application/routes/api/brews.rs index 491f47a..21d903f 100644 --- a/src/application/routes/api/brews.rs +++ b/src/application/routes/api/brews.rs @@ -190,6 +190,8 @@ pub(crate) struct NewBrewSubmission { #[serde(default, deserialize_with = "deserialize_quick_notes")] quick_notes: Vec, #[serde(default)] + brew_time: Option, + #[serde(default)] created_at: Option>, } @@ -209,6 +211,11 @@ impl NewBrewSubmission { "water temperature must be between 0 and 100", )); } + if let Some(bt) = self.brew_time + && bt <= 0 + { + return Err(AppError::validation("brew time must be positive")); + } Ok(NewBrew { bag_id: self.bag_id, @@ -220,6 +227,7 @@ impl NewBrewSubmission { water_volume: self.water_volume, water_temp: self.water_temp, quick_notes: self.quick_notes, + brew_time: self.brew_time, created_at: self.created_at, }) } diff --git a/src/domain/brews.rs b/src/domain/brews.rs index 1b38f0b..36c6da8 100644 --- a/src/domain/brews.rs +++ b/src/domain/brews.rs @@ -78,10 +78,16 @@ pub struct Brew { pub water_volume: i32, pub water_temp: f64, pub quick_notes: Vec, + pub brew_time: Option, pub created_at: DateTime, pub updated_at: DateTime, } +/// Format seconds as "M:SS" (e.g., 150 -> "2:30"). +pub fn format_brew_time(seconds: i32) -> String { + format!("{}:{:02}", seconds / 60, seconds % 60) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrewWithDetails { #[serde(flatten)] @@ -124,7 +130,10 @@ impl BrewWithDetails { }, TimelineEventDetail { label: "Grinder".to_string(), - value: format!("{} \u{00B7} {:.1}", self.grinder_name, self.brew.grind_setting), + value: format!( + "{} \u{00B7} {:.1}", + self.grinder_name, self.brew.grind_setting + ), }, TimelineEventDetail { label: "Brewer".to_string(), @@ -132,6 +141,17 @@ impl BrewWithDetails { }, ]; + if let Some(bt) = self.brew.brew_time { + // Insert after Water (index 2) so it appears right beneath it + details.insert( + 3, + TimelineEventDetail { + label: "Brew Time".to_string(), + value: format_brew_time(bt), + }, + ); + } + if let Some(ref fp_name) = self.filter_paper_name { details.push(TimelineEventDetail { label: "Filter".to_string(), @@ -171,6 +191,7 @@ impl BrewWithDetails { grind_setting: self.brew.grind_setting, water_volume: self.brew.water_volume, water_temp: self.brew.water_temp, + brew_time: self.brew.brew_time, }), } } @@ -188,6 +209,8 @@ pub struct NewBrew { pub water_temp: f64, pub quick_notes: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + pub brew_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub created_at: Option>, } diff --git a/src/domain/timeline.rs b/src/domain/timeline.rs index 51a5048..016ee16 100644 --- a/src/domain/timeline.rs +++ b/src/domain/timeline.rs @@ -21,6 +21,7 @@ pub struct TimelineBrewData { pub grind_setting: f64, pub water_volume: i32, pub water_temp: f64, + pub brew_time: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/infrastructure/backup.rs b/src/infrastructure/backup.rs index 71f21c1..bc8a6ba 100644 --- a/src/infrastructure/backup.rs +++ b/src/infrastructure/backup.rs @@ -210,7 +210,7 @@ impl BackupService { async fn export_brews(&self) -> anyhow::Result> { 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, quick_notes, 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, brew_time, created_at, updated_at FROM brews ORDER BY id", ) .fetch_all(&self.pool) .await @@ -410,7 +410,7 @@ impl BackupService { }; sqlx::query( - "INSERT INTO brews (id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO brews (id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, brew_time, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(i64::from(brew.id)) .bind(i64::from(brew.bag_id)) @@ -422,6 +422,7 @@ impl BackupService { .bind(brew.water_volume) .bind(brew.water_temp) .bind(quick_notes_json.as_deref()) + .bind(brew.brew_time) .bind(brew.created_at) .bind(brew.updated_at) .execute(&mut **tx) @@ -650,6 +651,7 @@ struct BrewRecord { water_volume: i32, water_temp: f64, quick_notes: Option, + brew_time: Option, created_at: DateTime, updated_at: DateTime, } @@ -675,6 +677,7 @@ impl BrewRecord { water_volume: self.water_volume, water_temp: self.water_temp, quick_notes, + brew_time: self.brew_time, created_at: self.created_at, updated_at: self.updated_at, } diff --git a/src/infrastructure/client/brews.rs b/src/infrastructure/client/brews.rs index 88dbe9a..749ab20 100644 --- a/src/infrastructure/client/brews.rs +++ b/src/infrastructure/client/brews.rs @@ -27,6 +27,7 @@ impl<'a> BrewsClient<'a> { water_volume: i32, water_temp: f64, quick_notes: Vec, + brew_time: Option, created_at: Option>, ) -> Result { let url = self.inner.endpoint("api/v1/brews")?; @@ -46,6 +47,9 @@ impl<'a> BrewsClient<'a> { let labels: Vec<&str> = quick_notes.iter().map(|n| n.label()).collect(); payload["quick_notes"] = serde_json::json!(labels); } + if let Some(bt) = brew_time { + payload["brew_time"] = serde_json::json!(bt); + } if let Some(ts) = created_at { payload["created_at"] = serde_json::json!(ts); } diff --git a/src/infrastructure/repositories/brews.rs b/src/infrastructure/repositories/brews.rs index a05f4ff..915837d 100644 --- a/src/infrastructure/repositories/brews.rs +++ b/src/infrastructure/repositories/brews.rs @@ -13,7 +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.quick_notes, br.brew_time, 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, @@ -90,6 +90,7 @@ impl SqlBrewRepository { water_volume: record.water_volume, water_temp: record.water_temp, quick_notes: Self::decode_quick_notes(record.quick_notes), + brew_time: record.brew_time, created_at: record.created_at, updated_at: record.updated_at, } @@ -108,6 +109,7 @@ impl SqlBrewRepository { water_volume: record.water_volume, water_temp: record.water_temp, quick_notes: Self::decode_quick_notes(record.quick_notes), + brew_time: record.brew_time, created_at: record.created_at, updated_at: record.updated_at, }, @@ -165,9 +167,9 @@ impl BrewRepository for SqlBrewRepository { // Insert the brew let created_at = brew.created_at.unwrap_or_else(Utc::now); let insert_query = r" - INSERT INTO brews (bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at) - 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 + INSERT INTO brews (bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, brew_time, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, brew_time, created_at, updated_at "; let record = query_as::<_, BrewRecord>(insert_query) @@ -183,6 +185,7 @@ impl BrewRepository for SqlBrewRepository { .bind(brew.water_volume) .bind(brew.water_temp) .bind(Self::encode_quick_notes(&brew.quick_notes)) + .bind(brew.brew_time) .bind(created_at) .bind(created_at) .fetch_one(&mut *tx) @@ -198,7 +201,7 @@ impl BrewRepository for SqlBrewRepository { async fn get(&self, id: BrewId) -> Result { let query = r" - 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 + SELECT id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, brew_time, created_at, updated_at FROM brews WHERE id = ? "; @@ -297,6 +300,7 @@ struct BrewRecord { water_volume: i32, water_temp: f64, quick_notes: Option, + brew_time: Option, created_at: DateTime, updated_at: DateTime, } @@ -313,6 +317,7 @@ struct BrewWithDetailsRecord { water_volume: i32, water_temp: f64, quick_notes: Option, + brew_time: Option, created_at: DateTime, updated_at: DateTime, roast_name: String, diff --git a/src/presentation/cli/brews.rs b/src/presentation/cli/brews.rs index f5dac88..154ae83 100644 --- a/src/presentation/cli/brews.rs +++ b/src/presentation/cli/brews.rs @@ -67,6 +67,10 @@ pub struct AddBrewCommand { #[arg(long, value_delimiter = ',')] pub quick_notes: Vec, + /// Brew time in seconds (e.g., 150 for 2:30) + #[arg(long)] + pub brew_time: Option, + /// Override creation timestamp (e.g. 2025-08-05T10:00:00Z or 2025-08-05) #[arg(long)] pub created_at: Option, @@ -95,6 +99,7 @@ pub async fn add_brew(client: &BrewlogClient, command: AddBrewCommand) -> Result command.water_volume, command.water_temp, quick_notes, + command.brew_time, created_at, ) .await?; diff --git a/src/presentation/web/views/brews.rs b/src/presentation/web/views/brews.rs index 54abd6d..89be968 100644 --- a/src/presentation/web/views/brews.rs +++ b/src/presentation/web/views/brews.rs @@ -1,4 +1,4 @@ -use crate::domain::brews::{Brew, BrewWithDetails, QuickNote}; +use crate::domain::brews::{Brew, BrewWithDetails, QuickNote, format_brew_time}; use super::relative_date; @@ -43,6 +43,7 @@ pub struct BrewView { pub water_volume: String, pub water_temp: String, pub ratio: String, + pub brew_time: Option, pub quick_notes: Vec, pub quick_notes_label: String, pub quick_notes_raw: String, @@ -54,6 +55,7 @@ pub struct BrewView { pub grind_setting_raw: f64, pub water_volume_raw: i32, pub water_temp_raw: f64, + pub brew_time_raw: Option, } impl BrewView { @@ -110,6 +112,7 @@ impl BrewView { water_volume: format!("{}ml", brew.brew.water_volume), water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp), ratio, + brew_time: brew.brew.brew_time.map(format_brew_time), quick_notes, quick_notes_label, quick_notes_raw, @@ -120,6 +123,7 @@ impl BrewView { grind_setting_raw: brew.brew.grind_setting, water_volume_raw: brew.brew.water_volume, water_temp_raw: brew.brew.water_temp, + brew_time_raw: brew.brew.brew_time, } } } @@ -133,6 +137,7 @@ pub struct BrewDefaultsView { pub grind_setting: f64, pub water_volume: i32, pub water_temp: f64, + pub brew_time: Option, } impl Default for BrewDefaultsView { @@ -147,6 +152,7 @@ impl Default for BrewDefaultsView { grind_setting: 6.0, water_volume: 250, water_temp: 91.0, + brew_time: Some(120), } } } @@ -166,6 +172,7 @@ impl From for BrewDefaultsView { grind_setting: brew.grind_setting, water_volume: brew.water_volume, water_temp: brew.water_temp, + brew_time: brew.brew_time, } } } diff --git a/src/presentation/web/views/timeline.rs b/src/presentation/web/views/timeline.rs index 8b2dd4b..8c9d826 100644 --- a/src/presentation/web/views/timeline.rs +++ b/src/presentation/web/views/timeline.rs @@ -40,6 +40,7 @@ pub struct TimelineBrewDataView { pub grind_setting: f64, pub water_volume: i32, pub water_temp: f64, + pub brew_time: Option, } #[derive(Clone)] @@ -131,6 +132,7 @@ impl TimelineEventView { grind_setting: bd.grind_setting, water_volume: bd.water_volume, water_temp: bd.water_temp, + brew_time: bd.brew_time, }); Self { diff --git a/templates/pages/add.html b/templates/pages/add.html index d045015..bae85d9 100644 --- a/templates/pages/add.html +++ b/templates/pages/add.html @@ -35,7 +35,7 @@ data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})" data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }" > - {{ scan::scan_input("scan-extract-form", "scan-image", "$_extracting", "$_extractError", "Describe the coffee bag…", "Waiting for response…") }} + {{ scan::scan_input("scan-extract-form", "scan-image", "$_extracting", "$_extractError", "Describe a coffee bag…", "Waiting for response…") }} @@ -68,6 +68,7 @@ data-signals:_brew-grind="{{ defaults.grind_setting }}" data-signals:_brew-volume="{{ defaults.water_volume }}" data-signals:_brew-weight="{{ defaults.coffee_weight }}" + data-signals:_brew-time="{{ defaults.brew_time.unwrap_or(120) }}" data-signals:_cafe-id="''" data-signals:_cafe-name="''" data-signals:_cafe-city="''" @@ -349,6 +350,20 @@ + +
+

Brew Time

+
+ Time (M:SS) +
+ + + + +
+
+

Quick Notes

diff --git a/templates/partials/brew_card.html b/templates/partials/brew_card.html index 7948957..0632c79 100644 --- a/templates/partials/brew_card.html +++ b/templates/partials/brew_card.html @@ -18,6 +18,9 @@ + {% if let Some(bt) = brew.brew_time_raw %} + + {% endif %}
-

{{ brew.coffee_weight }} · {{ brew.water_volume }}

+

{{ brew.coffee_weight }} · {{ brew.water_volume }}{% if let Some(bt) = brew.brew_time %} · {{ bt }}{% endif %}

{{ brew.grinder_model }} · {{ brew.grind_setting }}

{{ brew.brewer_name }}

{% if let Some(fp) = brew.filter_paper_name %}

{{ fp }}

{% endif %} diff --git a/templates/partials/lists/brew_list.html b/templates/partials/lists/brew_list.html index 5ee1c4c..fb4f16b 100644 --- a/templates/partials/lists/brew_list.html +++ b/templates/partials/lists/brew_list.html @@ -15,6 +15,9 @@ + {% if let Some(bt) = brew.brew_time_raw %} + + {% endif %}