feat: add pagination/infinite scroll to timeline
This commit is contained in:
parent
f60e759935
commit
4f69d20bb1
11 changed files with 785 additions and 215 deletions
|
|
@ -5,7 +5,7 @@ use crate::domain::roasters::RoasterSortKey;
|
||||||
use crate::domain::roasters::{Roaster, UpdateRoaster};
|
use crate::domain::roasters::{Roaster, UpdateRoaster};
|
||||||
use crate::domain::roasts::RoastSortKey;
|
use crate::domain::roasts::RoastSortKey;
|
||||||
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
|
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
|
||||||
use crate::domain::timeline::TimelineEvent;
|
use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -63,5 +63,16 @@ pub trait RoastRepository: Send + Sync {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TimelineEventRepository: Send + Sync {
|
pub trait TimelineEventRepository: Send + Sync {
|
||||||
async fn list_all(&self) -> Result<Vec<TimelineEvent>, RepositoryError>;
|
async fn list(
|
||||||
|
&self,
|
||||||
|
request: &ListRequest<TimelineSortKey>,
|
||||||
|
) -> Result<Page<TimelineEvent>, RepositoryError>;
|
||||||
|
|
||||||
|
async fn list_all(&self) -> Result<Vec<TimelineEvent>, RepositoryError> {
|
||||||
|
let sort_key = <TimelineSortKey as SortKey>::default();
|
||||||
|
let request =
|
||||||
|
ListRequest::<TimelineSortKey>::show_all(sort_key, sort_key.default_direction());
|
||||||
|
let page = self.list(&request).await?;
|
||||||
|
Ok(page.items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::listing::{SortDirection, SortKey};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TimelineEventDetail {
|
pub struct TimelineEventDetail {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
|
@ -27,3 +29,33 @@ pub struct NewTimelineEvent {
|
||||||
pub details: Vec<TimelineEventDetail>,
|
pub details: Vec<TimelineEventDetail>,
|
||||||
pub tasting_notes: Vec<String>,
|
pub tasting_notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub enum TimelineSortKey {
|
||||||
|
OccurredAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortKey for TimelineSortKey {
|
||||||
|
fn default() -> Self {
|
||||||
|
TimelineSortKey::OccurredAt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_query(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"occurred-at" => Some(TimelineSortKey::OccurredAt),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TimelineSortKey::OccurredAt => "occurred-at",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_direction(self) -> SortDirection {
|
||||||
|
match self {
|
||||||
|
TimelineSortKey::OccurredAt => SortDirection::Desc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde_json::from_str;
|
use serde_json::from_str;
|
||||||
use sqlx::query_as;
|
use sqlx::{query_as, query_scalar};
|
||||||
|
|
||||||
use crate::domain::RepositoryError;
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection};
|
||||||
use crate::domain::repositories::TimelineEventRepository;
|
use crate::domain::repositories::TimelineEventRepository;
|
||||||
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail};
|
use crate::domain::timeline::{TimelineEvent, TimelineEventDetail, TimelineSortKey};
|
||||||
use crate::infrastructure::database::DatabasePool;
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -21,20 +22,83 @@ impl SqlTimelineEventRepository {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TimelineEventRepository for SqlTimelineEventRepository {
|
impl TimelineEventRepository for SqlTimelineEventRepository {
|
||||||
async fn list_all(&self) -> Result<Vec<TimelineEvent>, RepositoryError> {
|
async fn list(
|
||||||
let records = query_as::<_, TimelineEventRecord>(
|
&self,
|
||||||
"SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json FROM timeline_events ORDER BY occurred_at DESC",
|
request: &ListRequest<TimelineSortKey>,
|
||||||
)
|
) -> Result<Page<TimelineEvent>, RepositoryError> {
|
||||||
.fetch_all(&self.pool)
|
let direction_sql = match request.sort_direction() {
|
||||||
.await
|
SortDirection::Asc => "ASC",
|
||||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
SortDirection::Desc => "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
let mut events = Vec::with_capacity(records.len());
|
let order_clause = format!("occurred_at {direction_sql}, id DESC");
|
||||||
for record in records {
|
|
||||||
events.push(record.into_domain()?);
|
match request.page_size() {
|
||||||
|
PageSize::All => {
|
||||||
|
let query = format!(
|
||||||
|
"SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \
|
||||||
|
FROM timeline_events \
|
||||||
|
ORDER BY {order_clause}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let records = query_as::<_, TimelineEventRecord>(&query)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(records.len());
|
||||||
|
for record in records {
|
||||||
|
events.push(record.into_domain()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = events.len() as u64;
|
||||||
|
let page_size = total.min(u64::from(u32::MAX)) as u32;
|
||||||
|
Ok(Page::new(events, 1, page_size.max(1), total, true))
|
||||||
|
}
|
||||||
|
PageSize::Limited(page_size) => {
|
||||||
|
let limit = page_size as i64;
|
||||||
|
let mut page = request.page();
|
||||||
|
let offset = ((page - 1) as i64).saturating_mul(limit);
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
"SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json \
|
||||||
|
FROM timeline_events \
|
||||||
|
ORDER BY {order_clause} \
|
||||||
|
LIMIT ? OFFSET ?"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut records = query_as::<_, TimelineEventRecord>(&query)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let total: i64 = query_scalar("SELECT COUNT(*) FROM timeline_events")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
if page > 1 && records.is_empty() && total > 0 {
|
||||||
|
let last_page = ((total + limit - 1) / limit) as u32;
|
||||||
|
page = last_page.max(1);
|
||||||
|
let offset = ((page - 1) as i64).saturating_mul(limit);
|
||||||
|
records = query_as::<_, TimelineEventRecord>(&query)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut events = Vec::with_capacity(records.len());
|
||||||
|
for record in records {
|
||||||
|
events.push(record.into_domain()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Page::new(events, page, page_size, total as u64, false))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(events)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
use super::views::{
|
use super::views::{
|
||||||
ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineMonthView,
|
ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView,
|
||||||
|
TimelineMonthView,
|
||||||
};
|
};
|
||||||
use crate::domain::roasters::RoasterSortKey;
|
use crate::domain::roasters::RoasterSortKey;
|
||||||
use crate::domain::roasts::RoastSortKey;
|
use crate::domain::roasts::RoastSortKey;
|
||||||
|
use crate::domain::timeline::TimelineSortKey;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "roasters.html")]
|
#[template(path = "roasters.html")]
|
||||||
|
|
@ -56,6 +58,16 @@ pub struct RoastListTemplate {
|
||||||
#[template(path = "timeline.html")]
|
#[template(path = "timeline.html")]
|
||||||
pub struct TimelineTemplate {
|
pub struct TimelineTemplate {
|
||||||
pub nav_active: &'static str,
|
pub nav_active: &'static str,
|
||||||
|
pub events: Paginated<TimelineEventView>,
|
||||||
|
pub navigator: ListNavigator<TimelineSortKey>,
|
||||||
|
pub months: Vec<TimelineMonthView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "partials/timeline_chunk.html")]
|
||||||
|
pub struct TimelineChunkTemplate {
|
||||||
|
pub events: Paginated<TimelineEventView>,
|
||||||
|
pub navigator: ListNavigator<TimelineSortKey>,
|
||||||
pub months: Vec<TimelineMonthView>,
|
pub months: Vec<TimelineMonthView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
|
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
|
||||||
use crate::domain::roasters::Roaster;
|
use crate::domain::roasters::Roaster;
|
||||||
use crate::domain::roasts::{Roast, RoastWithRoaster};
|
use crate::domain::roasts::{Roast, RoastWithRoaster};
|
||||||
|
use crate::domain::timeline::TimelineEvent;
|
||||||
|
|
||||||
pub struct Paginated<T> {
|
pub struct Paginated<T> {
|
||||||
pub items: Vec<T>,
|
pub items: Vec<T>,
|
||||||
|
|
@ -404,11 +405,13 @@ impl RoastView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct TimelineEventDetailView {
|
pub struct TimelineEventDetailView {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct TimelineEventView {
|
pub struct TimelineEventView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub kind_label: &'static str,
|
pub kind_label: &'static str,
|
||||||
|
|
@ -431,3 +434,77 @@ pub struct TimelineMonthView {
|
||||||
pub heading: String,
|
pub heading: String,
|
||||||
pub events: Vec<TimelineEventView>,
|
pub events: Vec<TimelineEventView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TimelineEventView {
|
||||||
|
pub fn from_domain(event: TimelineEvent) -> Self {
|
||||||
|
let TimelineEvent {
|
||||||
|
id,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
occurred_at,
|
||||||
|
title,
|
||||||
|
details,
|
||||||
|
tasting_notes,
|
||||||
|
} = event;
|
||||||
|
|
||||||
|
let kind_label = match entity_type.as_str() {
|
||||||
|
"roaster" => "Roaster Added",
|
||||||
|
"roast" => "Roast Added",
|
||||||
|
_ => "Event",
|
||||||
|
};
|
||||||
|
|
||||||
|
let link = match entity_type.as_str() {
|
||||||
|
"roaster" => format!("/roasters/{entity_id}"),
|
||||||
|
"roast" => format!("/roasts/{entity_id}"),
|
||||||
|
_ => String::from("#"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut mapped_details = Vec::new();
|
||||||
|
let mut external_link = None;
|
||||||
|
for detail in details {
|
||||||
|
if detail.label.eq_ignore_ascii_case("homepage") {
|
||||||
|
let trimmed = detail.value.trim();
|
||||||
|
if !trimmed.is_empty() && trimmed != "—" {
|
||||||
|
external_link = Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mapped_details.push(TimelineEventDetailView {
|
||||||
|
label: detail.label,
|
||||||
|
value: detail.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tasting_notes = if entity_type == "roast" {
|
||||||
|
let notes = tasting_notes
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|note| {
|
||||||
|
note.split(|ch| ch == ',' || ch == '\n')
|
||||||
|
.map(|segment| segment.trim().to_string())
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Some(notes)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
kind_label,
|
||||||
|
badge_class: "bg-amber-200 text-amber-800",
|
||||||
|
accent_class: "bg-amber-600",
|
||||||
|
card_border_class: "border-amber-200 bg-amber-50/80",
|
||||||
|
title_class: "text-amber-800",
|
||||||
|
date_label: occurred_at.format("%B %d, %Y").to_string(),
|
||||||
|
time_label: Some(occurred_at.format("%H:%M UTC").to_string()),
|
||||||
|
iso_timestamp: occurred_at.to_rfc3339(),
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
external_link,
|
||||||
|
details: mapped_details,
|
||||||
|
tasting_notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,15 @@ enum PageSizeParam {
|
||||||
|
|
||||||
impl ListQuery {
|
impl ListQuery {
|
||||||
pub fn into_request<K: SortKey>(self) -> ListRequest<K> {
|
pub fn into_request<K: SortKey>(self) -> ListRequest<K> {
|
||||||
|
self.into_request_with_default::<K>(DEFAULT_PAGE_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_request_with_default<K: SortKey>(self, default_page_size: u32) -> ListRequest<K> {
|
||||||
let page = self.page.unwrap_or(1);
|
let page = self.page.unwrap_or(1);
|
||||||
let page_size = match self.page_size {
|
let page_size = match self.page_size {
|
||||||
Some(PageSizeParam::Number(value)) => PageSize::limited(value),
|
Some(PageSizeParam::Number(value)) => PageSize::limited(value),
|
||||||
Some(PageSizeParam::Text(text)) => page_size_from_text(&text),
|
Some(PageSizeParam::Text(text)) => page_size_from_text(&text),
|
||||||
None => PageSize::limited(DEFAULT_PAGE_SIZE),
|
None => PageSize::limited(default_page_size.max(1)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let sort_key = self
|
let sort_key = self
|
||||||
|
|
|
||||||
|
|
@ -1,118 +1,153 @@
|
||||||
use axum::extract::State;
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::Html;
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
|
||||||
use crate::presentation::templates::TimelineTemplate;
|
use crate::domain::listing::ListRequest;
|
||||||
use crate::presentation::views::{TimelineEventDetailView, TimelineEventView, TimelineMonthView};
|
use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
|
||||||
|
use crate::presentation::templates::{TimelineChunkTemplate, TimelineTemplate};
|
||||||
|
use crate::presentation::views::{ListNavigator, Paginated, TimelineEventView, TimelineMonthView};
|
||||||
use crate::server::errors::{AppError, map_app_error};
|
use crate::server::errors::{AppError, map_app_error};
|
||||||
use crate::server::routes::render_html;
|
use crate::server::routes::render_html;
|
||||||
|
use crate::server::routes::support::{
|
||||||
|
ListQuery, is_datastar_request, normalize_request, set_datastar_patch_headers,
|
||||||
|
};
|
||||||
use crate::server::server::AppState;
|
use crate::server::server::AppState;
|
||||||
|
|
||||||
|
const TIMELINE_PAGE_PATH: &str = "/timeline";
|
||||||
|
const TIMELINE_FRAGMENT_PATH: &str = "/timeline";
|
||||||
|
const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 5;
|
||||||
|
|
||||||
pub(crate) async fn timeline_page(
|
pub(crate) async fn timeline_page(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Html<String>, StatusCode> {
|
headers: HeaderMap,
|
||||||
let events = state
|
Query(query): Query<ListQuery>,
|
||||||
.timeline_repo
|
) -> Result<Response, StatusCode> {
|
||||||
.list_all()
|
let request = query.into_request_with_default::<TimelineSortKey>(TIMELINE_DEFAULT_PAGE_SIZE);
|
||||||
.await
|
if is_datastar_request(&headers) {
|
||||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
return render_timeline_chunk(state, request)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = load_timeline_page(&state, request)
|
||||||
|
.await
|
||||||
|
.map_err(|err| map_app_error(err))?;
|
||||||
|
|
||||||
|
let template = TimelineTemplate {
|
||||||
|
nav_active: "timeline",
|
||||||
|
events: data.events,
|
||||||
|
navigator: data.navigator,
|
||||||
|
months: data.months,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimelinePreparedEvent {
|
||||||
|
anchor: String,
|
||||||
|
heading: String,
|
||||||
|
view: TimelineEventView,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_timeline_chunk(
|
||||||
|
state: AppState,
|
||||||
|
request: ListRequest<TimelineSortKey>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let data = load_timeline_page(&state, request).await?;
|
||||||
|
let template = TimelineChunkTemplate {
|
||||||
|
events: data.events,
|
||||||
|
navigator: data.navigator,
|
||||||
|
months: data.months,
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = crate::presentation::templates::render_template(template)
|
||||||
|
.map_err(|err| AppError::unexpected(format!("failed to render timeline chunk: {err}")))?;
|
||||||
|
|
||||||
|
let mut response = Html(html).into_response();
|
||||||
|
set_datastar_patch_headers(response.headers_mut(), "#timeline-loader");
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimelinePageData {
|
||||||
|
events: Paginated<TimelineEventView>,
|
||||||
|
navigator: ListNavigator<TimelineSortKey>,
|
||||||
|
months: Vec<TimelineMonthView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_timeline_page(
|
||||||
|
state: &AppState,
|
||||||
|
request: ListRequest<TimelineSortKey>,
|
||||||
|
) -> Result<TimelinePageData, AppError> {
|
||||||
|
let page = state
|
||||||
|
.timeline_repo
|
||||||
|
.list(&request)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let normalized_request = normalize_request(request, &page);
|
||||||
|
|
||||||
|
let prepared_events = page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(prepare_event)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let views = prepared_events
|
||||||
|
.iter()
|
||||||
|
.map(|prepared| prepared.view.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let events = Paginated::new(
|
||||||
|
views,
|
||||||
|
page.page,
|
||||||
|
page.page_size,
|
||||||
|
page.total,
|
||||||
|
page.showing_all,
|
||||||
|
);
|
||||||
|
let months = build_months(prepared_events);
|
||||||
|
let navigator = ListNavigator::new(
|
||||||
|
TIMELINE_PAGE_PATH,
|
||||||
|
TIMELINE_FRAGMENT_PATH,
|
||||||
|
normalized_request,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(TimelinePageData {
|
||||||
|
events,
|
||||||
|
navigator,
|
||||||
|
months,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_event(event: TimelineEvent) -> TimelinePreparedEvent {
|
||||||
|
let anchor = event.occurred_at.format("%Y-%m").to_string();
|
||||||
|
let heading = event.occurred_at.format("%B %Y").to_string();
|
||||||
|
let view = TimelineEventView::from_domain(event);
|
||||||
|
|
||||||
|
TimelinePreparedEvent {
|
||||||
|
anchor,
|
||||||
|
heading,
|
||||||
|
view,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_months(prepared_events: Vec<TimelinePreparedEvent>) -> Vec<TimelineMonthView> {
|
||||||
let mut months: Vec<TimelineMonthView> = Vec::new();
|
let mut months: Vec<TimelineMonthView> = Vec::new();
|
||||||
|
|
||||||
for event in events {
|
for prepared in prepared_events {
|
||||||
let occurred_at = event.occurred_at;
|
|
||||||
let anchor = occurred_at.format("%Y-%m").to_string();
|
|
||||||
let heading = occurred_at.format("%B %Y").to_string();
|
|
||||||
|
|
||||||
let entity_id = event.entity_id.clone();
|
|
||||||
let kind_label = match event.entity_type.as_str() {
|
|
||||||
"roaster" => "Roaster Added",
|
|
||||||
"roast" => "Roast Added",
|
|
||||||
_ => "Event",
|
|
||||||
};
|
|
||||||
let link = match event.entity_type.as_str() {
|
|
||||||
"roaster" => format!("/roasters/{entity_id}"),
|
|
||||||
"roast" => format!("/roasts/{entity_id}"),
|
|
||||||
_ => String::from("#"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut details: Vec<TimelineEventDetailView> = event
|
|
||||||
.details
|
|
||||||
.into_iter()
|
|
||||||
.map(|detail| TimelineEventDetailView {
|
|
||||||
label: detail.label,
|
|
||||||
value: detail.value,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let external_link = if let Some(index) = details
|
|
||||||
.iter()
|
|
||||||
.position(|detail| detail.label.eq_ignore_ascii_case("homepage"))
|
|
||||||
{
|
|
||||||
let value = details.remove(index).value;
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() || trimmed == "—" {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let tasting_notes = if event.entity_type == "roast" {
|
|
||||||
let notes = event
|
|
||||||
.tasting_notes
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.flat_map(|note| {
|
|
||||||
note.split(|ch| ch == ',' || ch == '\n')
|
|
||||||
.map(|segment| segment.trim())
|
|
||||||
.filter(|segment| !segment.is_empty())
|
|
||||||
.map(|segment| segment.to_string())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
Some(notes)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let view = TimelineEventView {
|
|
||||||
id: event.id,
|
|
||||||
kind_label,
|
|
||||||
badge_class: "bg-amber-200 text-amber-800",
|
|
||||||
accent_class: "bg-amber-600",
|
|
||||||
card_border_class: "border-amber-200 bg-amber-50/80",
|
|
||||||
title_class: "text-amber-800",
|
|
||||||
date_label: occurred_at.format("%B %d, %Y").to_string(),
|
|
||||||
time_label: Some(occurred_at.format("%H:%M UTC").to_string()),
|
|
||||||
iso_timestamp: occurred_at.to_rfc3339(),
|
|
||||||
title: event.title,
|
|
||||||
link,
|
|
||||||
external_link,
|
|
||||||
details,
|
|
||||||
tasting_notes,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(last) = months.last_mut() {
|
if let Some(last) = months.last_mut() {
|
||||||
if last.anchor == anchor {
|
if last.anchor == prepared.anchor {
|
||||||
last.events.push(view);
|
last.events.push(prepared.view);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
months.push(TimelineMonthView {
|
months.push(TimelineMonthView {
|
||||||
anchor,
|
anchor: prepared.anchor,
|
||||||
heading,
|
heading: prepared.heading,
|
||||||
events: vec![view],
|
events: vec![prepared.view],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let template = TimelineTemplate {
|
months
|
||||||
nav_active: "timeline",
|
|
||||||
months,
|
|
||||||
};
|
|
||||||
|
|
||||||
render_html(template)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
templates/partials/timeline_chunk.html
Normal file
20
templates/partials/timeline_chunk.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<div
|
||||||
|
data-timeline-chunk
|
||||||
|
data-next-url="{% if events.has_next() %}{{ navigator.fragment_page_href(events.next_page().unwrap()) }}{% else %}{% endif %}"
|
||||||
|
data-has-more="{{ events.has_next() }}"
|
||||||
|
>
|
||||||
|
<div data-chunk-months>
|
||||||
|
{% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
|
||||||
|
</div>
|
||||||
|
<ul data-chunk-nav-items class="hidden">
|
||||||
|
{% for month in months %}
|
||||||
|
<li data-timeline-nav-item>
|
||||||
|
<a
|
||||||
|
href="#{{ month.anchor }}"
|
||||||
|
class="block rounded-md border border-transparent px-3 py-2 transition hover:border-amber-400 hover:bg-amber-100/70 hover:text-amber-700"
|
||||||
|
>{{ month.heading }}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
74
templates/partials/timeline_month.html
Normal file
74
templates/partials/timeline_month.html
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<div id="{{ month.anchor }}" class="scroll-mt-24" data-timeline-month>
|
||||||
|
<h2 class="text-2xl font-semibold text-amber-800">{{ month.heading }}</h2>
|
||||||
|
<ol class="mt-6 space-y-8 border-l-2 border-amber-200 pl-4 sm:pl-8">
|
||||||
|
{% for event in month.events %}
|
||||||
|
<li class="relative pl-6 sm:pl-10" data-timeline-event>
|
||||||
|
<span
|
||||||
|
class="absolute left-[-10px] top-2 h-5 w-5 rounded-full border-[6px] border-amber-50 {{ event.accent_class }} sm:left-[-14px]"
|
||||||
|
></span>
|
||||||
|
<div class="rounded-lg border {{ event.card_border_class }} p-5 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {{ event.badge_class }}"
|
||||||
|
>{{ event.kind_label }}</span
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
datetime="{{ event.iso_timestamp }}"
|
||||||
|
class="text-xs uppercase tracking-wide text-stone-500"
|
||||||
|
>
|
||||||
|
{{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{% endif
|
||||||
|
%}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold {{ event.title_class }}">
|
||||||
|
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
|
||||||
|
{% if let Some(url) = event.external_link %}
|
||||||
|
<a
|
||||||
|
href="{{ url }}"
|
||||||
|
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
aria-label="Open external link"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Open external link</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
{% if event.details.len() > 0 %}
|
||||||
|
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
|
||||||
|
{% for detail in event.details %}
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="font-medium text-stone-500">{{ detail.label }}</dt>
|
||||||
|
<dd class="text-right">{{ detail.value }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %}
|
||||||
|
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="mt-4 flex flex-wrap gap-2">
|
||||||
|
{% for note in notes %}
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||||
|
>{{ note }}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %} {% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
@ -7,94 +7,65 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-12 lg:grid-cols-[minmax(0,1fr),14rem]">
|
<div class="mt-8 grid gap-12 lg:grid-cols-[minmax(0,1fr),14rem]">
|
||||||
<section class="space-y-12">
|
<section
|
||||||
|
class="space-y-12"
|
||||||
|
id="timeline-events"
|
||||||
|
data-role="timeline-months"
|
||||||
|
data-empty="{{ months.is_empty() }}"
|
||||||
|
>
|
||||||
{% if months.is_empty() %}
|
{% if months.is_empty() %}
|
||||||
<p
|
<p
|
||||||
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-6 text-sm text-stone-600"
|
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-6 text-sm text-stone-600"
|
||||||
|
data-role="timeline-empty-state"
|
||||||
>
|
>
|
||||||
No events yet. Create roasters or roasts to populate the timeline.
|
No events yet. Create roasters or roasts to populate the timeline.
|
||||||
</p>
|
</p>
|
||||||
{% else %} {% for month in months %}
|
{% else %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
|
||||||
<div id="{{ month.anchor }}" class="scroll-mt-24">
|
{% endif %}
|
||||||
<h2 class="text-2xl font-semibold text-amber-800">{{ month.heading }}</h2>
|
|
||||||
<ol class="mt-6 space-y-8 border-l-2 border-amber-200 pl-4 sm:pl-8">
|
<div
|
||||||
{% for event in month.events %}
|
id="timeline-loader"
|
||||||
<li class="relative pl-6 sm:pl-10">
|
class="mt-8 flex flex-col items-center gap-3"
|
||||||
<span
|
data-next-url="{% if events.has_next() %}{{ navigator.fragment_page_href(events.next_page().unwrap()) }}{% else %}{% endif %}"
|
||||||
class="absolute left-[-10px] top-2 h-5 w-5 rounded-full border-[6px] border-amber-50 {{ event.accent_class }} sm:left-[-14px]"
|
data-has-more="{{ events.has_next() }}"
|
||||||
></span>
|
data-empty="{{ months.is_empty() }}"
|
||||||
<div class="rounded-lg border {{ event.card_border_class }} p-5 shadow-sm">
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<button
|
||||||
<span
|
id="timeline-load-more"
|
||||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {{ event.badge_class }}"
|
type="button"
|
||||||
>{{ event.kind_label }}</span
|
class="inline-flex items-center gap-2 rounded-full border border-amber-500 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
|
||||||
>
|
{%
|
||||||
<time
|
if
|
||||||
datetime="{{ event.iso_timestamp }}"
|
months.is_empty()
|
||||||
class="text-xs uppercase tracking-wide text-stone-500"
|
%}hidden{%
|
||||||
>
|
endif
|
||||||
{{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{%
|
%}
|
||||||
endif %}
|
{%
|
||||||
</time>
|
if
|
||||||
</div>
|
!events.has_next()
|
||||||
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold {{ event.title_class }}">
|
%}disabled{%
|
||||||
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
|
endif
|
||||||
{% if let Some(url) = event.external_link %}
|
%}
|
||||||
<a
|
>
|
||||||
href="{{ url }}"
|
<span aria-hidden="true">↓</span>
|
||||||
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500"
|
<span>Load more</span>
|
||||||
target="_blank"
|
</button>
|
||||||
rel="noreferrer noopener"
|
<p id="timeline-status" class="text-xs text-stone-500" hidden>Loading…</p>
|
||||||
aria-label="Open external link"
|
<p
|
||||||
>
|
id="timeline-end"
|
||||||
<svg
|
class="text-sm text-stone-500"
|
||||||
class="h-3.5 w-3.5"
|
{%
|
||||||
viewBox="0 0 20 20"
|
if
|
||||||
fill="currentColor"
|
events.has_next()
|
||||||
aria-hidden="true"
|
%}hidden{%
|
||||||
>
|
endif
|
||||||
<path
|
%}
|
||||||
fill-rule="evenodd"
|
>
|
||||||
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
|
No more events.
|
||||||
clip-rule="evenodd"
|
</p>
|
||||||
/>
|
<p id="timeline-error" class="text-sm text-red-600" hidden></p>
|
||||||
<path
|
|
||||||
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Open external link</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</h3>
|
|
||||||
{% if event.details.len() > 0 %}
|
|
||||||
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
|
|
||||||
{% for detail in event.details %}
|
|
||||||
<div class="flex justify-between gap-2">
|
|
||||||
<dt class="font-medium text-stone-500">{{ detail.label }}</dt>
|
|
||||||
<dd class="text-right">{{ detail.value }}</dd>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</dl>
|
|
||||||
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %}
|
|
||||||
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
|
|
||||||
{% else %}
|
|
||||||
<ul class="mt-4 flex flex-wrap gap-2">
|
|
||||||
{% for note in notes %}
|
|
||||||
<li>
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
|
||||||
>{{ note }}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %} {% endif %}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %} {% endif %}
|
<div id="timeline-sentinel" class="h-1"></div>
|
||||||
</section>
|
</section>
|
||||||
<aside class="sticky top-24 self-start lg:top-28">
|
<aside class="sticky top-24 self-start lg:top-28">
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-4 shadow-sm">
|
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-4 shadow-sm">
|
||||||
|
|
@ -104,17 +75,179 @@
|
||||||
Timeline navigation will appear once events are available.
|
Timeline navigation will appear once events are available.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<nav class="mt-3 flex flex-col gap-2 text-sm">
|
<nav class="mt-3 text-sm">
|
||||||
{% for month in months %}
|
<ul class="flex flex-col gap-2" data-role="timeline-nav-list">
|
||||||
<a
|
{% for month in months %}
|
||||||
href="#{{ month.anchor }}"
|
<li data-timeline-nav-item>
|
||||||
class="rounded-md border border-transparent px-3 py-2 transition hover:border-amber-400 hover:bg-amber-100/70 hover:text-amber-700"
|
<a
|
||||||
>{{ month.heading }}</a
|
href="#{{ month.anchor }}"
|
||||||
>
|
class="block rounded-md border border-transparent px-3 py-2 transition hover:border-amber-400 hover:bg-amber-100/70 hover:text-amber-700"
|
||||||
{% endfor %}
|
>{{ month.heading }}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
const loader = document.getElementById("timeline-loader")
|
||||||
|
const monthsContainer = document.getElementById("timeline-events")
|
||||||
|
const navList = document.querySelector('[data-role="timeline-nav-list"]')
|
||||||
|
const loadMoreButton = document.getElementById("timeline-load-more")
|
||||||
|
const statusLine = document.getElementById("timeline-status")
|
||||||
|
const endLine = document.getElementById("timeline-end")
|
||||||
|
const errorLine = document.getElementById("timeline-error")
|
||||||
|
const sentinel = document.getElementById("timeline-sentinel")
|
||||||
|
const emptyState = document.querySelector('[data-role="timeline-empty-state"]')
|
||||||
|
|
||||||
|
if (loader && monthsContainer && sentinel) {
|
||||||
|
let nextUrl = loader.dataset.nextUrl || ""
|
||||||
|
let loading = false
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
void loadMore()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" }
|
||||||
|
)
|
||||||
|
|
||||||
|
const setHasMore = (hasMore, url) => {
|
||||||
|
loader.dataset.hasMore = hasMore ? "true" : "false"
|
||||||
|
nextUrl = url || ""
|
||||||
|
loader.dataset.nextUrl = nextUrl
|
||||||
|
if (loadMoreButton) {
|
||||||
|
loadMoreButton.disabled = !hasMore
|
||||||
|
}
|
||||||
|
if (endLine) {
|
||||||
|
endLine.hidden = hasMore || loader.dataset.empty === "true"
|
||||||
|
}
|
||||||
|
if (hasMore) {
|
||||||
|
observer.observe(sentinel)
|
||||||
|
} else {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendMonths = (chunk) => {
|
||||||
|
const monthsFragment = chunk.querySelector("[data-chunk-months]")
|
||||||
|
if (!monthsFragment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const monthNode of Array.from(monthsFragment.children)) {
|
||||||
|
const anchor = monthNode.id
|
||||||
|
if (!anchor) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const existing = document.getElementById(anchor)
|
||||||
|
if (existing) {
|
||||||
|
const existingList = existing.querySelector("ol")
|
||||||
|
const newList = monthNode.querySelector("ol")
|
||||||
|
if (existingList && newList) {
|
||||||
|
existingList.append(...Array.from(newList.children))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
monthsContainer.appendChild(monthNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendNavItems = (chunk) => {
|
||||||
|
if (!navList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const navItemsContainer = chunk.querySelector("[data-chunk-nav-items]")
|
||||||
|
if (!navItemsContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const item of Array.from(navItemsContainer.children)) {
|
||||||
|
const link = item.querySelector("a")
|
||||||
|
if (!link) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const href = link.getAttribute("href")
|
||||||
|
if (!href) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!navList.querySelector(`a[href="${href}"]`)) {
|
||||||
|
navList.appendChild(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearEmptyState = () => {
|
||||||
|
if (emptyState && !emptyState.hidden) {
|
||||||
|
emptyState.hidden = true
|
||||||
|
loader.dataset.empty = "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (!nextUrl || loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading = true
|
||||||
|
if (statusLine) {
|
||||||
|
statusLine.hidden = false
|
||||||
|
}
|
||||||
|
if (errorLine) {
|
||||||
|
errorLine.hidden = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(nextUrl, {
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "fetch",
|
||||||
|
"datastar-request": "true",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unexpected response: ${response.status}`)
|
||||||
|
}
|
||||||
|
const html = await response.text()
|
||||||
|
const template = document.createElement("template")
|
||||||
|
template.innerHTML = html.trim()
|
||||||
|
const chunk = template.content.querySelector("[data-timeline-chunk]")
|
||||||
|
if (!chunk) {
|
||||||
|
throw new Error("Invalid timeline chunk payload")
|
||||||
|
}
|
||||||
|
appendMonths(chunk)
|
||||||
|
appendNavItems(chunk)
|
||||||
|
clearEmptyState()
|
||||||
|
|
||||||
|
const hasMore = chunk.dataset.hasMore === "true"
|
||||||
|
const url = chunk.dataset.nextUrl || ""
|
||||||
|
setHasMore(hasMore, url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
if (errorLine) {
|
||||||
|
errorLine.textContent = "Failed to load more events. Please try again."
|
||||||
|
errorLine.hidden = false
|
||||||
|
}
|
||||||
|
if (loadMoreButton) {
|
||||||
|
loadMoreButton.disabled = false
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
if (statusLine) {
|
||||||
|
statusLine.hidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadMoreButton) {
|
||||||
|
loadMoreButton.addEventListener("click", () => {
|
||||||
|
void loadMore()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loader.dataset.empty !== "true" && nextUrl) {
|
||||||
|
observer.observe(sentinel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -26,20 +26,49 @@ async fn create_roast(app: &crate::helpers::TestApp, roaster_id: &str, name: &st
|
||||||
assert_eq!(response.status(), 201);
|
assert_eq!(response.status(), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn seed_timeline_with_roasts(
|
||||||
|
app: &crate::helpers::TestApp,
|
||||||
|
roast_count: usize,
|
||||||
|
) -> (String, Vec<String>) {
|
||||||
|
let roaster_name = "Timeline Seed Roasters";
|
||||||
|
let roaster = create_roaster_with_payload(
|
||||||
|
app,
|
||||||
|
NewRoaster {
|
||||||
|
name: roaster_name.to_string(),
|
||||||
|
country: "UK".to_string(),
|
||||||
|
city: Some("Bristol".to_string()),
|
||||||
|
homepage: Some("https://example.com".to_string()),
|
||||||
|
notes: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Ensure the roaster event predates the roast events.
|
||||||
|
sleep(Duration::from_millis(5)).await;
|
||||||
|
|
||||||
|
let mut roast_names = Vec::new();
|
||||||
|
for index in 0..roast_count {
|
||||||
|
let roast_name = format!("Seed Roast {index:02}");
|
||||||
|
create_roast(app, &roaster.id, &roast_name).await;
|
||||||
|
roast_names.push(roast_name);
|
||||||
|
// Space out timestamps to keep ordering deterministic.
|
||||||
|
sleep(Duration::from_millis(2)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
(roaster_name.to_string(), roast_names)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn timeline_page_returns_a_200_with_empty_state() {
|
async fn timeline_page_returns_a_200_with_empty_state() {
|
||||||
// Arrange
|
|
||||||
let app = spawn_app().await;
|
let app = spawn_app().await;
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(format!("{}/timeline", app.address))
|
.get(format!("{}/timeline", app.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("failed to fetch timeline");
|
.expect("failed to fetch timeline");
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
let body = response.text().await.expect("failed to read response body");
|
let body = response.text().await.expect("failed to read response body");
|
||||||
|
|
@ -51,7 +80,6 @@ async fn timeline_page_returns_a_200_with_empty_state() {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn creating_a_roaster_surfaces_on_the_timeline() {
|
async fn creating_a_roaster_surfaces_on_the_timeline() {
|
||||||
// Arrange
|
|
||||||
let app = spawn_app().await;
|
let app = spawn_app().await;
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
|
|
@ -69,17 +97,14 @@ async fn creating_a_roaster_surfaces_on_the_timeline() {
|
||||||
.await;
|
.await;
|
||||||
let roaster_id = roaster.id.clone();
|
let roaster_id = roaster.id.clone();
|
||||||
|
|
||||||
// Give the database a brief moment to commit timestamps
|
|
||||||
sleep(Duration::from_millis(10)).await;
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(format!("{}/timeline", app.address))
|
.get(format!("{}/timeline", app.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("failed to fetch timeline");
|
.expect("failed to fetch timeline");
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
let body = response.text().await.expect("failed to read response body");
|
let body = response.text().await.expect("failed to read response body");
|
||||||
|
|
@ -99,7 +124,6 @@ async fn creating_a_roaster_surfaces_on_the_timeline() {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn creating_a_roast_surfaces_on_the_timeline() {
|
async fn creating_a_roast_surfaces_on_the_timeline() {
|
||||||
// Arrange
|
|
||||||
let app = spawn_app().await;
|
let app = spawn_app().await;
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
|
|
@ -115,19 +139,17 @@ async fn creating_a_roast_surfaces_on_the_timeline() {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.id;
|
.id;
|
||||||
// Ensure the roast event occurs after the roaster event to make ordering deterministic
|
|
||||||
sleep(Duration::from_millis(5)).await;
|
sleep(Duration::from_millis(5)).await;
|
||||||
let roast_name = "Timeline Natural";
|
let roast_name = "Timeline Natural";
|
||||||
create_roast(&app, &roaster_id, roast_name).await;
|
create_roast(&app, &roaster_id, roast_name).await;
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(format!("{}/timeline", app.address))
|
.get(format!("{}/timeline", app.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("failed to fetch timeline");
|
.expect("failed to fetch timeline");
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
let body = response.text().await.expect("failed to read response body");
|
let body = response.text().await.expect("failed to read response body");
|
||||||
|
|
@ -144,3 +166,89 @@ async fn creating_a_roast_surfaces_on_the_timeline() {
|
||||||
"Expected tasting notes to appear in timeline HTML, got: {body}"
|
"Expected tasting notes to appear in timeline HTML, got: {body}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeline_page_signals_more_results_when_over_page_size() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let (_, roast_names) = seed_timeline_with_roasts(&app, 6).await;
|
||||||
|
assert_eq!(roast_names.len(), 6);
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/timeline", app.address))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to fetch timeline");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
let body = response.text().await.expect("failed to read response body");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
body.contains(
|
||||||
|
"data-next-url=\"/timeline?page=2&page_size=5&sort=occurred-at&dir=desc\""
|
||||||
|
),
|
||||||
|
"Expected loader next-page URL missing from timeline HTML:\n{}",
|
||||||
|
body
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("data-has-more=\"true\""),
|
||||||
|
"Expected loader to signal additional pages"
|
||||||
|
);
|
||||||
|
|
||||||
|
let latest_roast = roast_names.last().unwrap();
|
||||||
|
assert!(
|
||||||
|
body.contains(latest_roast),
|
||||||
|
"Expected most recent roast '{latest_roast}' to appear in first page HTML"
|
||||||
|
);
|
||||||
|
|
||||||
|
let event_occurrences = body.matches("data-timeline-event").count();
|
||||||
|
assert_eq!(
|
||||||
|
event_occurrences, 5,
|
||||||
|
"Expected exactly 5 events on first page"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeline_chunk_endpoint_serves_remaining_events() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let (roaster_name, roast_names) = seed_timeline_with_roasts(&app, 6).await;
|
||||||
|
let oldest_roast = roast_names
|
||||||
|
.first()
|
||||||
|
.expect("missing seeded roast name")
|
||||||
|
.clone();
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let chunk_url = format!(
|
||||||
|
"{}/timeline?page=2&page_size=5&sort=occurred-at&dir=desc",
|
||||||
|
app.address
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(chunk_url)
|
||||||
|
.header("datastar-request", "true")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to fetch timeline chunk");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
let body = response.text().await.expect("failed to read response body");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
body.contains(&oldest_roast),
|
||||||
|
"Expected chunk payload to include oldest roast '{oldest_roast}':\n{}",
|
||||||
|
body
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(&roaster_name),
|
||||||
|
"Expected chunk payload to include the roaster event: {body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("data-has-more=\"false\""),
|
||||||
|
"Expected chunk to disable further pagination"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("data-next-url=\"\""),
|
||||||
|
"Expected chunk to clear next URL once exhausted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue