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![
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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()))?;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
.bind(i64::from(roast.roaster_id))
|
let roaster_info: Option<(String, String)> =
|
||||||
.fetch_optional(&mut *tx)
|
query_as("SELECT name, slug FROM roasters WHERE id = ?")
|
||||||
.await
|
.bind(i64::from(roast.roaster_id))
|
||||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
.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![
|
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()))?;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue