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:
parent
267ef2bf17
commit
949f65ac16
11 changed files with 151 additions and 103 deletions
69
migrations/0010_denormalize_timeline.sql
Normal file
69
migrations/0010_denormalize_timeline.sql
Normal 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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ pub struct NewTimelineEvent {
|
|||
pub title: String,
|
||||
pub details: Vec<TimelineEventDetail>,
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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::<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)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
|
|
|||
|
|
@ -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<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))
|
||||
.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::<Option<&str>>(None) // brew_data_json not applicable
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ impl SqlTimelineEventRepository {
|
|||
impl TimelineEventRepository for SqlTimelineEventRepository {
|
||||
async fn insert(&self, event: NewTimelineEvent) -> Result<TimelineEvent, RepositoryError> {
|
||||
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<String>,
|
||||
slug: Option<String>,
|
||||
roaster_slug: Option<String>,
|
||||
// Brew-specific fields (only populated for brew events)
|
||||
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>,
|
||||
brew_data_json: Option<String>,
|
||||
}
|
||||
|
||||
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::<TimelineBrewData>(&raw).map_err(|err| {
|
||||
RepositoryError::unexpected(format!("failed to decode brew data: {err}"))
|
||||
})?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
{# Central timeline line #}
|
||||
<div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div>
|
||||
{% 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 #}
|
||||
<span
|
||||
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600"
|
||||
|
|
|
|||
|
|
@ -118,24 +118,24 @@ a {
|
|||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Left side items (odd) */
|
||||
.timeline-item-left {
|
||||
/* Left side items (odd) - uses nth-of-type to skip the timeline-line div */
|
||||
.timeline-item:nth-of-type(odd) {
|
||||
align-self: flex-start;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.timeline-item-left .timeline-node {
|
||||
.timeline-item:nth-of-type(odd) .timeline-node {
|
||||
right: -10px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
/* Right side items (even) */
|
||||
.timeline-item-right {
|
||||
.timeline-item:nth-of-type(even) {
|
||||
align-self: flex-end;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.timeline-item-right .timeline-node {
|
||||
.timeline-item:nth-of-type(even) .timeline-node {
|
||||
left: -10px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,22 +96,12 @@
|
|||
}
|
||||
const existing = document.getElementById(anchor)
|
||||
if (existing) {
|
||||
// Merge new events into existing month
|
||||
const existingList = existing.querySelector("ol")
|
||||
const newList = monthNode.querySelector("ol")
|
||||
if (existingList && newList) {
|
||||
// Count existing items to continue the alternating pattern
|
||||
const existingCount = existingList.querySelectorAll(".timeline-item").length
|
||||
// Filter to only timeline-items (exclude the timeline-line div)
|
||||
// CSS :nth-of-type handles alternating pattern automatically
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in a new issue