refactor(timeline): denormalize data and simplify frontend

Database:
- Add slug, roaster_slug, brew_data_json columns to timeline_events
- Migration backfills existing data from related tables
- Remove 9-way LEFT JOIN from list query, read directly from columns

Frontend:
- Use CSS :nth-of-type(odd/even) for alternating timeline layout
- Remove JavaScript class manipulation when appending month events
- Simplify infinite scroll month-merging logic

This eliminates query-time JOINs across 5 tables and ~20 lines of
client-side JavaScript for pattern maintenance.
This commit is contained in:
Jon Seager 2026-02-03 08:53:29 +00:00
parent 267ef2bf17
commit 949f65ac16
No known key found for this signature in database
11 changed files with 151 additions and 103 deletions

View file

@ -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)

View file

@ -153,7 +153,7 @@ pub(crate) async fn create_bag(
details: vec![ details: vec![
TimelineEventDetail { TimelineEventDetail {
label: "Roaster".to_string(), label: "Roaster".to_string(),
value: roaster.name, value: roaster.name.clone(),
}, },
TimelineEventDetail { TimelineEventDetail {
label: "Amount".to_string(), label: "Amount".to_string(),
@ -161,6 +161,9 @@ pub(crate) async fn create_bag(
}, },
], ],
tasting_notes: vec![], tasting_notes: vec![],
slug: Some(roast.slug.clone()),
roaster_slug: Some(roaster.slug.clone()),
brew_data: None,
}; };
let _ = state.timeline_repo.insert(event).await; let _ = state.timeline_repo.insert(event).await;
@ -254,7 +257,7 @@ pub(crate) async fn update_bag(
details: vec![ details: vec![
TimelineEventDetail { TimelineEventDetail {
label: "Roaster".to_string(), label: "Roaster".to_string(),
value: roaster.name, value: roaster.name.clone(),
}, },
TimelineEventDetail { TimelineEventDetail {
label: "Amount".to_string(), label: "Amount".to_string(),
@ -262,6 +265,9 @@ pub(crate) async fn update_bag(
}, },
], ],
tasting_notes: vec![], tasting_notes: vec![],
slug: Some(roast.slug.clone()),
roaster_slug: Some(roaster.slug.clone()),
brew_data: None,
}; };
let _ = state.timeline_repo.insert(event).await; let _ = state.timeline_repo.insert(event).await;
} }

View file

@ -17,7 +17,7 @@ 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, 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::templates::{BrewListTemplate, BrewsTemplate};
use crate::presentation::web::views::{ use crate::presentation::web::views::{
BagOptionView, BrewView, GearOptionView, ListNavigator, Paginated, BagOptionView, BrewView, GearOptionView, ListNavigator, Paginated,
@ -250,6 +250,17 @@ pub(crate) async fn create_brew(
}, },
], ],
tasting_notes: vec![], 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; let _ = state.timeline_repo.insert(event).await;

View file

@ -119,6 +119,9 @@ pub(crate) async fn create_gear(
}, },
], ],
tasting_notes: vec![], 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; let _ = state.timeline_repo.insert(event).await;

View file

@ -46,6 +46,9 @@ pub struct NewTimelineEvent {
pub title: String, pub title: String,
pub details: Vec<TimelineEventDetail>, pub details: Vec<TimelineEventDetail>,
pub tasting_notes: Vec<String>, pub tasting_notes: Vec<String>,
pub slug: Option<String>,
pub roaster_slug: Option<String>,
pub brew_data: Option<TimelineBrewData>,
} }
#[derive(Debug, Clone, Copy, Eq, PartialEq)] #[derive(Debug, Clone, Copy, Eq, PartialEq)]

View file

@ -134,7 +134,7 @@ impl RoasterRepository for SqlRoasterRepository {
let details_json = Self::details_for_roaster(&roaster)?; let details_json = Self::details_for_roaster(&roaster)?;
query( 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("roaster")
.bind(i64::from(roaster.id)) .bind(i64::from(roaster.id))
@ -143,6 +143,9 @@ impl RoasterRepository for SqlRoasterRepository {
.bind(&roaster.name) .bind(&roaster.name)
.bind(details_json) .bind(details_json)
.bind::<Option<&str>>(None) .bind::<Option<&str>>(None)
.bind(&roaster.slug) // slug = roaster's own slug
.bind::<Option<&str>>(None) // roaster_slug not applicable for roaster events
.bind::<Option<&str>>(None) // brew_data_json not applicable
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;

View file

@ -1,7 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde_json::{from_str, to_string}; 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 super::macros::push_update_field;
use crate::domain::RepositoryError; use crate::domain::RepositoryError;
@ -125,13 +125,18 @@ impl RoastRepository for SqlRoastRepository {
let roast = record.into_roast()?; let roast = record.into_roast()?;
let roaster_name: Option<String> = query_scalar("SELECT name FROM roasters WHERE id = ?") // 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)) .bind(i64::from(roast.roaster_id))
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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![ let details = vec![
TimelineEventDetail { TimelineEventDetail {
@ -171,7 +176,7 @@ impl RoastRepository for SqlRoastRepository {
}; };
query( 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("roast")
.bind(i64::from(roast.id)) .bind(i64::from(roast.id))
@ -180,6 +185,9 @@ impl RoastRepository for SqlRoastRepository {
.bind(&roast.name) .bind(&roast.name)
.bind(details_json) .bind(details_json)
.bind(tasting_notes_json.as_deref()) .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::<Option<&str>>(None) // brew_data_json not applicable
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;

View file

@ -25,9 +25,9 @@ impl SqlTimelineEventRepository {
impl TimelineEventRepository for SqlTimelineEventRepository { impl TimelineEventRepository for SqlTimelineEventRepository {
async fn insert(&self, event: NewTimelineEvent) -> Result<TimelineEvent, RepositoryError> { async fn insert(&self, event: NewTimelineEvent) -> Result<TimelineEvent, RepositoryError> {
let query = r" let query = r"
INSERT INTO timeline_events (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 (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json 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| { 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) let record = sqlx::query_as::<_, TimelineEventRecord>(query)
.bind(event.entity_type) .bind(event.entity_type)
.bind(event.entity_id) .bind(event.entity_id)
@ -48,6 +57,9 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
.bind(event.title) .bind(event.title)
.bind(details_json) .bind(details_json)
.bind(tasting_notes_json) .bind(tasting_notes_json)
.bind(event.slug)
.bind(event.roaster_slug)
.bind(brew_data_json)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
@ -64,44 +76,15 @@ impl TimelineEventRepository for SqlTimelineEventRepository {
SortDirection::Desc => "DESC", 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 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, id, entity_type, entity_id, action, occurred_at, title,
CASE details_json, tasting_notes_json, slug, roaster_slug, brew_data_json
WHEN t.entity_type = 'roaster' THEN r.slug FROM timeline_events";
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";
let count_query = "SELECT COUNT(*) FROM timeline_events t"; let count_query = "SELECT COUNT(*) FROM timeline_events";
crate::infrastructure::repositories::pagination::paginate( crate::infrastructure::repositories::pagination::paginate(
&self.pool, &self.pool,
@ -127,14 +110,7 @@ struct TimelineEventRecord {
tasting_notes_json: Option<String>, tasting_notes_json: Option<String>,
slug: Option<String>, slug: Option<String>,
roaster_slug: Option<String>, roaster_slug: Option<String>,
// Brew-specific fields (only populated for brew events) brew_data_json: Option<String>,
brew_bag_id: Option<i64>,
brew_grinder_id: Option<i64>,
brew_brewer_id: Option<i64>,
brew_coffee_weight: Option<f64>,
brew_grind_setting: Option<f64>,
brew_water_volume: Option<i32>,
brew_water_temp: Option<f64>,
} }
impl TimelineEventRecord { impl TimelineEventRecord {
@ -159,33 +135,12 @@ impl TimelineEventRecord {
_ => Vec::new(), _ => Vec::new(),
}; };
// Build brew_data if all required fields are present let brew_data = match self.brew_data_json {
let brew_data = match ( Some(raw) if !raw.is_empty() => {
self.brew_bag_id, Some(from_str::<TimelineBrewData>(&raw).map_err(|err| {
self.brew_grinder_id, RepositoryError::unexpected(format!("failed to decode brew data: {err}"))
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,
}),
_ => None, _ => None,
}; };

View file

@ -11,7 +11,7 @@
{# Central timeline line #} {# Central timeline line #}
<div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div> <div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div>
{% for event in month.events %} {% for event in month.events %}
<li class="timeline-item {% if loop.index % 2 == 1 %}timeline-item-left{% else %}timeline-item-right{% endif %} relative mb-8 last:mb-0" data-timeline-event> <li class="timeline-item relative mb-8 last:mb-0" data-timeline-event>
{# Timeline node/bullet #} {# Timeline node/bullet #}
<span <span
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600" class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600"

View file

@ -118,24 +118,24 @@ a {
padding-right: 0; padding-right: 0;
} }
/* Left side items (odd) */ /* Left side items (odd) - uses nth-of-type to skip the timeline-line div */
.timeline-item-left { .timeline-item:nth-of-type(odd) {
align-self: flex-start; align-self: flex-start;
padding-right: 2rem; padding-right: 2rem;
} }
.timeline-item-left .timeline-node { .timeline-item:nth-of-type(odd) .timeline-node {
right: -10px; right: -10px;
left: auto; left: auto;
} }
/* Right side items (even) */ /* Right side items (even) */
.timeline-item-right { .timeline-item:nth-of-type(even) {
align-self: flex-end; align-self: flex-end;
padding-left: 2rem; padding-left: 2rem;
} }
.timeline-item-right .timeline-node { .timeline-item:nth-of-type(even) .timeline-node {
left: -10px; left: -10px;
} }

View file

@ -96,22 +96,12 @@
} }
const existing = document.getElementById(anchor) const existing = document.getElementById(anchor)
if (existing) { if (existing) {
// Merge new events into existing month
const existingList = existing.querySelector("ol") const existingList = existing.querySelector("ol")
const newList = monthNode.querySelector("ol") const newList = monthNode.querySelector("ol")
if (existingList && newList) { if (existingList && newList) {
// Count existing items to continue the alternating pattern // CSS :nth-of-type handles alternating pattern automatically
const existingCount = existingList.querySelectorAll(".timeline-item").length
// Filter to only timeline-items (exclude the timeline-line div)
const newItems = Array.from(newList.querySelectorAll(".timeline-item")) const newItems = Array.from(newList.querySelectorAll(".timeline-item"))
// Adjust left/right classes to continue the pattern
newItems.forEach((item, i) => {
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) existingList.append(...newItems)
} }
} else { } else {