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::{is_datastar_request, normalize_request}; use crate::application::state::AppState; 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::{ ListNavigator, Paginated, TimelineEventView, TimelineMonthView, }; const TIMELINE_PAGE_PATH: &str = "/timeline"; const TIMELINE_FRAGMENT_PATH: &str = "/timeline"; 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, ) -> Result { let request = query.to_request(); let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; if is_datastar_request(&headers) { return render_timeline_chunk(state, request, is_authenticated) .await .map_err(map_app_error); } let data = load_timeline_page(&state, request) .await .map_err(map_app_error)?; let template = TimelineTemplate { nav_active: "timeline", is_authenticated, version_info: &crate::VERSION_INFO, 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, is_authenticated: bool, ) -> Result { let data = load_timeline_page(&state, request).await?; let template = TimelineChunkTemplate { is_authenticated, events: data.events, navigator: data.navigator, months: data.months, }; crate::application::routes::support::render_fragment(template, "#timeline-loader") } struct TimelinePageData { events: Paginated, navigator: ListNavigator, months: Vec, } #[tracing::instrument(skip(state))] async fn load_timeline_page( state: &AppState, request: ListRequest, ) -> Result { 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::>(); let views = prepared_events .iter() .map(|prepared| prepared.view.clone()) .collect::>(); 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, None, ); 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) -> Vec { let mut months: Vec = Vec::new(); for prepared in prepared_events { if let Some(last) = months.last_mut() && last.anchor == prepared.anchor { last.events.push(prepared.view); continue; } months.push(TimelineMonthView { anchor: prepared.anchor, heading: prepared.heading, events: vec![prepared.view], }); } months }