diff --git a/migrations/0010_denormalize_timeline.sql b/migrations/0010_denormalize_timeline.sql new file mode 100644 index 0000000..96376eb --- /dev/null +++ b/migrations/0010_denormalize_timeline.sql @@ -0,0 +1,69 @@ +-- Add denormalized slug columns to timeline_events +-- This eliminates the need for 9-way JOINs when listing timeline events + +-- Step 1: Add the new columns +ALTER TABLE timeline_events ADD COLUMN slug TEXT; +ALTER TABLE timeline_events ADD COLUMN roaster_slug TEXT; +ALTER TABLE timeline_events ADD COLUMN brew_data_json TEXT; + +-- Step 2: Backfill roaster slugs for roaster events +UPDATE timeline_events +SET slug = (SELECT slug FROM roasters WHERE roasters.id = timeline_events.entity_id) +WHERE entity_type = 'roaster'; + +-- Step 3: Backfill slugs for roast events +UPDATE timeline_events +SET slug = (SELECT slug FROM roasts WHERE roasts.id = timeline_events.entity_id), + roaster_slug = ( + SELECT ro.slug FROM roasts r + JOIN roasters ro ON r.roaster_id = ro.id + WHERE r.id = timeline_events.entity_id + ) +WHERE entity_type = 'roast'; + +-- Step 4: Backfill slugs for bag events +UPDATE timeline_events +SET slug = ( + SELECT r.slug FROM bags b + JOIN roasts r ON b.roast_id = r.id + WHERE b.id = timeline_events.entity_id + ), + roaster_slug = ( + SELECT ro.slug FROM bags b + JOIN roasts r ON b.roast_id = r.id + JOIN roasters ro ON r.roaster_id = ro.id + WHERE b.id = timeline_events.entity_id + ) +WHERE entity_type = 'bag'; + +-- Step 5: Backfill slugs and brew_data for brew events +UPDATE timeline_events +SET slug = ( + SELECT r.slug FROM brews br + JOIN bags b ON br.bag_id = b.id + JOIN roasts r ON b.roast_id = r.id + WHERE br.id = timeline_events.entity_id + ), + roaster_slug = ( + SELECT ro.slug FROM brews br + JOIN bags b ON br.bag_id = b.id + JOIN roasts r ON b.roast_id = r.id + JOIN roasters ro ON r.roaster_id = ro.id + WHERE br.id = timeline_events.entity_id + ), + brew_data_json = ( + SELECT json_object( + 'bag_id', br.bag_id, + 'grinder_id', br.grinder_id, + 'brewer_id', br.brewer_id, + 'coffee_weight', br.coffee_weight, + 'grind_setting', br.grind_setting, + 'water_volume', br.water_volume, + 'water_temp', br.water_temp + ) + FROM brews br + WHERE br.id = timeline_events.entity_id + ) +WHERE entity_type = 'brew'; + +-- Gear events have no slug (entity_type = 'gear' gets NULL for both slug columns) diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index f27f2ed..4edb19c 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -153,7 +153,7 @@ pub(crate) async fn create_bag( details: vec![ TimelineEventDetail { label: "Roaster".to_string(), - value: roaster.name, + value: roaster.name.clone(), }, TimelineEventDetail { label: "Amount".to_string(), @@ -161,6 +161,9 @@ pub(crate) async fn create_bag( }, ], tasting_notes: vec![], + slug: Some(roast.slug.clone()), + roaster_slug: Some(roaster.slug.clone()), + brew_data: None, }; let _ = state.timeline_repo.insert(event).await; @@ -254,7 +257,7 @@ pub(crate) async fn update_bag( details: vec![ TimelineEventDetail { label: "Roaster".to_string(), - value: roaster.name, + value: roaster.name.clone(), }, TimelineEventDetail { label: "Amount".to_string(), @@ -262,6 +265,9 @@ pub(crate) async fn update_bag( }, ], tasting_notes: vec![], + slug: Some(roast.slug.clone()), + roaster_slug: Some(roaster.slug.clone()), + brew_data: None, }; let _ = state.timeline_repo.insert(event).await; } diff --git a/src/application/routes/brews.rs b/src/application/routes/brews.rs index d079fbf..221386e 100644 --- a/src/application/routes/brews.rs +++ b/src/application/routes/brews.rs @@ -17,7 +17,7 @@ use crate::domain::brews::{BrewFilter, BrewSortKey, BrewWithDetails, NewBrew}; use crate::domain::gear::{GearCategory, GearFilter, GearSortKey}; use crate::domain::ids::{BagId, BrewId, GearId}; use crate::domain::listing::{ListRequest, SortDirection}; -use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; +use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail}; use crate::presentation::web::templates::{BrewListTemplate, BrewsTemplate}; use crate::presentation::web::views::{ BagOptionView, BrewView, GearOptionView, ListNavigator, Paginated, @@ -250,6 +250,17 @@ pub(crate) async fn create_brew( }, ], tasting_notes: vec![], + slug: Some(enriched.roast_slug.clone()), + roaster_slug: Some(enriched.roaster_slug.clone()), + brew_data: Some(TimelineBrewData { + bag_id: enriched.brew.bag_id.into_inner(), + grinder_id: enriched.brew.grinder_id.into_inner(), + brewer_id: enriched.brew.brewer_id.into_inner(), + coffee_weight: enriched.brew.coffee_weight, + grind_setting: enriched.brew.grind_setting, + water_volume: enriched.brew.water_volume, + water_temp: enriched.brew.water_temp, + }), }; let _ = state.timeline_repo.insert(event).await; diff --git a/src/application/routes/gear.rs b/src/application/routes/gear.rs index be9da2e..622bc82 100644 --- a/src/application/routes/gear.rs +++ b/src/application/routes/gear.rs @@ -119,6 +119,9 @@ pub(crate) async fn create_gear( }, ], tasting_notes: vec![], + slug: None, // Gear has no slug + roaster_slug: None, // Gear is not related to roasters + brew_data: None, }; let _ = state.timeline_repo.insert(event).await; diff --git a/src/domain/timeline.rs b/src/domain/timeline.rs index c0344a7..13647d1 100644 --- a/src/domain/timeline.rs +++ b/src/domain/timeline.rs @@ -46,6 +46,9 @@ pub struct NewTimelineEvent { pub title: String, pub details: Vec, pub tasting_notes: Vec, + pub slug: Option, + pub roaster_slug: Option, + pub brew_data: Option, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] diff --git a/src/infrastructure/repositories/roasters.rs b/src/infrastructure/repositories/roasters.rs index c13a089..84dc640 100644 --- a/src/infrastructure/repositories/roasters.rs +++ b/src/infrastructure/repositories/roasters.rs @@ -134,7 +134,7 @@ impl RoasterRepository for SqlRoasterRepository { let details_json = Self::details_for_roaster(&roaster)?; query( - "INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind("roaster") .bind(i64::from(roaster.id)) @@ -143,6 +143,9 @@ impl RoasterRepository for SqlRoasterRepository { .bind(&roaster.name) .bind(details_json) .bind::>(None) + .bind(&roaster.slug) // slug = roaster's own slug + .bind::>(None) // roaster_slug not applicable for roaster events + .bind::>(None) // brew_data_json not applicable .execute(&mut *tx) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index dc949a6..c16a1b4 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde_json::{from_str, to_string}; -use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar}; +use sqlx::{Error as SqlxError, QueryBuilder, query, query_as}; use super::macros::push_update_field; use crate::domain::RepositoryError; @@ -125,13 +125,18 @@ impl RoastRepository for SqlRoastRepository { let roast = record.into_roast()?; - let roaster_name: Option = query_scalar("SELECT name FROM roasters WHERE id = ?") - .bind(i64::from(roast.roaster_id)) - .fetch_optional(&mut *tx) - .await - .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + // Fetch roaster info for timeline event + let roaster_info: Option<(String, String)> = + query_as("SELECT name, slug FROM roasters WHERE id = ?") + .bind(i64::from(roast.roaster_id)) + .fetch_optional(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - let roaster_label = roaster_name.unwrap_or_else(|| "Unknown roaster".to_string()); + let (roaster_label, roaster_slug) = roaster_info.map_or_else( + || ("Unknown roaster".to_string(), None), + |(name, slug)| (name, Some(slug)), + ); let details = vec![ TimelineEventDetail { @@ -171,7 +176,7 @@ impl RoastRepository for SqlRoastRepository { }; query( - "INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind("roast") .bind(i64::from(roast.id)) @@ -180,6 +185,9 @@ impl RoastRepository for SqlRoastRepository { .bind(&roast.name) .bind(details_json) .bind(tasting_notes_json.as_deref()) + .bind(&roast.slug) // slug = roast's own slug + .bind(roaster_slug.as_deref()) // roaster_slug from the roaster + .bind::>(None) // brew_data_json not applicable .execute(&mut *tx) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index d214a1a..d1e0814 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -25,9 +25,9 @@ impl SqlTimelineEventRepository { impl TimelineEventRepository for SqlTimelineEventRepository { async fn insert(&self, event: NewTimelineEvent) -> Result { let query = r" - INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json) - VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json + INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json "; let details_json = serde_json::to_string(&event.details).map_err(|err| { @@ -40,6 +40,15 @@ impl TimelineEventRepository for SqlTimelineEventRepository { )) })?; + let brew_data_json = event + .brew_data + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|err| { + RepositoryError::unexpected(format!("failed to encode brew data: {err}")) + })?; + let record = sqlx::query_as::<_, TimelineEventRecord>(query) .bind(event.entity_type) .bind(event.entity_id) @@ -48,6 +57,9 @@ impl TimelineEventRepository for SqlTimelineEventRepository { .bind(event.title) .bind(details_json) .bind(tasting_notes_json) + .bind(event.slug) + .bind(event.roaster_slug) + .bind(brew_data_json) .fetch_one(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; @@ -64,44 +76,15 @@ impl TimelineEventRepository for SqlTimelineEventRepository { SortDirection::Desc => "DESC", }; - let order_clause = format!("t.occurred_at {direction_sql}, t.id DESC"); + let order_clause = format!("occurred_at {direction_sql}, id DESC"); + // All data is now denormalized in the timeline_events table - no JOINs needed let base_query = r"SELECT - t.id, t.entity_type, t.entity_id, t.action, t.occurred_at, t.title, t.details_json, t.tasting_notes_json, - CASE - WHEN t.entity_type = 'roaster' THEN r.slug - WHEN t.entity_type = 'roast' THEN rst.slug - WHEN t.entity_type = 'bag' THEN b_r.slug - WHEN t.entity_type = 'brew' THEN brew_roast.slug - ELSE NULL - END as slug, - CASE - WHEN t.entity_type = 'roast' THEN rst_r.slug - WHEN t.entity_type = 'bag' THEN b_rr.slug - WHEN t.entity_type = 'brew' THEN brew_roaster.slug - ELSE NULL - END as roaster_slug, - brew.bag_id as brew_bag_id, - brew.grinder_id as brew_grinder_id, - brew.brewer_id as brew_brewer_id, - brew.coffee_weight as brew_coffee_weight, - brew.grind_setting as brew_grind_setting, - brew.water_volume as brew_water_volume, - brew.water_temp as brew_water_temp - FROM timeline_events t - LEFT JOIN roasters r ON t.entity_type = 'roaster' AND t.entity_id = r.id - LEFT JOIN roasts rst ON t.entity_type = 'roast' AND t.entity_id = rst.id - LEFT JOIN roasters rst_r ON rst.roaster_id = rst_r.id - LEFT JOIN bags b ON t.entity_type = 'bag' AND t.entity_id = b.id - LEFT JOIN roasts b_r ON b.roast_id = b_r.id - LEFT JOIN roasters b_rr ON b_r.roaster_id = b_rr.id - LEFT JOIN gear g ON t.entity_type = 'gear' AND t.entity_id = g.id - LEFT JOIN brews brew ON t.entity_type = 'brew' AND t.entity_id = brew.id - LEFT JOIN bags brew_bag ON brew.bag_id = brew_bag.id - LEFT JOIN roasts brew_roast ON brew_bag.roast_id = brew_roast.id - LEFT JOIN roasters brew_roaster ON brew_roast.roaster_id = brew_roaster.id"; + id, entity_type, entity_id, action, occurred_at, title, + details_json, tasting_notes_json, slug, roaster_slug, brew_data_json + FROM timeline_events"; - let count_query = "SELECT COUNT(*) FROM timeline_events t"; + let count_query = "SELECT COUNT(*) FROM timeline_events"; crate::infrastructure::repositories::pagination::paginate( &self.pool, @@ -127,14 +110,7 @@ struct TimelineEventRecord { tasting_notes_json: Option, slug: Option, roaster_slug: Option, - // Brew-specific fields (only populated for brew events) - brew_bag_id: Option, - brew_grinder_id: Option, - brew_brewer_id: Option, - brew_coffee_weight: Option, - brew_grind_setting: Option, - brew_water_volume: Option, - brew_water_temp: Option, + brew_data_json: Option, } impl TimelineEventRecord { @@ -159,33 +135,12 @@ impl TimelineEventRecord { _ => Vec::new(), }; - // Build brew_data if all required fields are present - let brew_data = match ( - self.brew_bag_id, - self.brew_grinder_id, - self.brew_brewer_id, - self.brew_coffee_weight, - self.brew_grind_setting, - self.brew_water_volume, - self.brew_water_temp, - ) { - ( - Some(bag_id), - Some(grinder_id), - Some(brewer_id), - Some(coffee_weight), - Some(grind_setting), - Some(water_volume), - Some(water_temp), - ) => Some(TimelineBrewData { - bag_id, - grinder_id, - brewer_id, - coffee_weight, - grind_setting, - water_volume, - water_temp, - }), + let brew_data = match self.brew_data_json { + Some(raw) if !raw.is_empty() => { + Some(from_str::(&raw).map_err(|err| { + RepositoryError::unexpected(format!("failed to decode brew data: {err}")) + })?) + } _ => None, }; diff --git a/templates/partials/timeline_month.html b/templates/partials/timeline_month.html index 8d5be62..7f1e13c 100644 --- a/templates/partials/timeline_month.html +++ b/templates/partials/timeline_month.html @@ -11,7 +11,7 @@ {# Central timeline line #} {% for event in month.events %} -
  • +
  • {# Timeline node/bullet #} { - const newIndex = existingCount + i + 1 // 1-based - const shouldBeLeft = newIndex % 2 === 1 - item.classList.remove("timeline-item-left", "timeline-item-right") - item.classList.add(shouldBeLeft ? "timeline-item-left" : "timeline-item-right") - }) - existingList.append(...newItems) } } else {