From 267ef2bf176d531297a92cb29887432b6fd1bd18 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 2 Feb 2026 20:38:03 +0000 Subject: [PATCH] feat(timeline): remove sidebar nav, include sticky headers on scroll - Simplify timeline layout to single-column without sidebar - Update tests to explicitly pass page_size for pagination testing - Include sticky headers and a neater alternating, side-by-side timeline design --- src/application/routes/timeline.rs | 62 ++++++- .../repositories/timeline_events.rs | 6 +- templates/partials/timeline_chunk.html | 11 -- templates/partials/timeline_month.html | 21 ++- templates/styles.css | 102 +++++++++++ templates/timeline.html | 163 +++++++----------- tests/server/timeline.rs | 4 +- 7 files changed, 249 insertions(+), 120 deletions(-) diff --git a/src/application/routes/timeline.rs b/src/application/routes/timeline.rs index e6c92af..367c5a9 100644 --- a/src/application/routes/timeline.rs +++ b/src/application/routes/timeline.rs @@ -2,12 +2,13 @@ use axum::extract::{Query, State}; use axum::http::HeaderMap; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; +use serde::Deserialize; use crate::application::errors::{AppError, map_app_error}; use crate::application::routes::render_html; -use crate::application::routes::support::{ListQuery, is_datastar_request, normalize_request}; +use crate::application::routes::support::{is_datastar_request, normalize_request}; use crate::application::server::AppState; -use crate::domain::listing::ListRequest; +use crate::domain::listing::{ListRequest, PageSize, SortDirection, SortKey}; use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; use crate::presentation::web::templates::{TimelineChunkTemplate, TimelineTemplate}; use crate::presentation::web::views::{ @@ -16,16 +17,67 @@ use crate::presentation::web::views::{ const TIMELINE_PAGE_PATH: &str = "/timeline"; const TIMELINE_FRAGMENT_PATH: &str = "/timeline"; -const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 5; +const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 20; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PageSizeParam { + Number(u32), + Text(String), +} + +#[derive(Debug, Deserialize)] +pub struct TimelineQuery { + page: Option, + #[serde(default)] + page_size: Option, + #[serde(default, rename = "sort")] + sort_key: Option, + #[serde(default, rename = "dir")] + sort_dir: Option, +} + +impl TimelineQuery { + fn to_request(&self) -> ListRequest { + let page = self.page.unwrap_or(1); + let page_size = match &self.page_size { + Some(PageSizeParam::Number(value)) => PageSize::limited(*value), + Some(PageSizeParam::Text(text)) if text.eq_ignore_ascii_case("all") => PageSize::All, + Some(PageSizeParam::Text(text)) => text + .parse::() + .map(PageSize::limited) + .unwrap_or(PageSize::limited(TIMELINE_DEFAULT_PAGE_SIZE)), + None => PageSize::limited(TIMELINE_DEFAULT_PAGE_SIZE), + }; + + let sort_key = self + .sort_key + .as_deref() + .and_then(TimelineSortKey::from_query) + .unwrap_or_else(TimelineSortKey::default); + + let sort_direction = self + .sort_dir + .as_deref() + .and_then(|dir| match dir.to_ascii_lowercase().as_str() { + "asc" => Some(SortDirection::Asc), + "desc" => Some(SortDirection::Desc), + _ => None, + }) + .unwrap_or_else(|| sort_key.default_direction()); + + ListRequest::new(page, page_size, sort_key, sort_direction) + } +} #[tracing::instrument(skip(state, cookies, headers, query))] pub(crate) async fn timeline_page( State(state): State, cookies: tower_cookies::Cookies, headers: HeaderMap, - Query(query): Query, + Query(query): Query, ) -> Result { - let request = query.into_request_with_default::(TIMELINE_DEFAULT_PAGE_SIZE); + let request = query.to_request(); let is_authenticated = super::is_authenticated(&state, &cookies).await; if is_datastar_request(&headers) { diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index 6035daa..d214a1a 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -65,7 +65,8 @@ impl TimelineEventRepository for SqlTimelineEventRepository { }; let order_clause = format!("t.occurred_at {direction_sql}, t.id DESC"); - let base_query = "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, CASE WHEN t.entity_type = 'roaster' THEN r.slug @@ -99,7 +100,8 @@ impl TimelineEventRepository for SqlTimelineEventRepository { 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"; + + let count_query = "SELECT COUNT(*) FROM timeline_events t"; crate::infrastructure::repositories::pagination::paginate( &self.pool, diff --git a/templates/partials/timeline_chunk.html b/templates/partials/timeline_chunk.html index e5536d5..642b896 100644 --- a/templates/partials/timeline_chunk.html +++ b/templates/partials/timeline_chunk.html @@ -6,15 +6,4 @@
{% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
- diff --git a/templates/partials/timeline_month.html b/templates/partials/timeline_month.html index d5da79b..8d5be62 100644 --- a/templates/partials/timeline_month.html +++ b/templates/partials/timeline_month.html @@ -1,12 +1,23 @@
-

{{ month.heading }}

-
    +

    + {{ month.heading }} + +

    +
      + {# Central timeline line #} + {% for event in month.events %} -
    1. +
    2. + {# Timeline node/bullet #} -
      +
      -
      +
      - No events yet. Create roasters or roasts to populate the timeline. + No events yet.

      {% else %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %} {% endif %} @@ -34,69 +34,22 @@ id="timeline-load-more" type="button" 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" - {% - if - months.is_empty() - %}hidden{% - endif - %} - {% - if - !events.has_next() - %}disabled{% - endif - %} + style="{% if months.is_empty() || !events.has_next() %}display: none{% endif %}" > - Load more + Load more -

      - No more events. -

      +

      No more events.

      -
      {% endblock %} diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs index 71f092f..1ca10d7 100644 --- a/tests/server/timeline.rs +++ b/tests/server/timeline.rs @@ -249,8 +249,9 @@ async fn timeline_page_signals_more_results_when_over_page_size() { let client = Client::new(); + // Explicitly request page_size=5 to test pagination with 6 events let response = client - .get(format!("{}/timeline", app.address)) + .get(format!("{}/timeline?page_size=5", app.address)) .send() .await .expect("failed to fetch timeline"); @@ -293,6 +294,7 @@ async fn timeline_chunk_endpoint_serves_remaining_events() { .clone(); let client = Client::new(); + // page_size=5 to test pagination with 6 events let chunk_url = format!( "{}/timeline?page=2&page_size=5&sort=occurred-at&dir=desc", app.address