use askama::Template; use chrono::Datelike; use application::ports::{ ActivityFeedPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData, }; use domain::models::{ DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserSummary, UserTrends, collections::Paginated, }; struct PageItem { number: u32, is_current: bool, is_ellipsis: bool, } fn build_page_items(total_pages: u32, current_page: u32) -> Vec { if total_pages <= 1 { return vec![]; } let mut set = std::collections::BTreeSet::new(); set.insert(0u32); set.insert(total_pages - 1); let start = current_page.saturating_sub(2); let end = (current_page + 2).min(total_pages - 1); for p in start..=end { set.insert(p); } let pages: Vec = set.into_iter().collect(); let mut items = Vec::new(); for (i, &p) in pages.iter().enumerate() { if i > 0 && p > pages[i - 1] + 1 { items.push(PageItem { number: 0, is_current: false, is_ellipsis: true }); } items.push(PageItem { number: p, is_current: p == current_page, is_ellipsis: false }); } items } #[derive(Template)] #[template(path = "diary.html")] struct DiaryTemplate<'a> { entries: &'a [DiaryEntry], current_offset: u32, limit: u32, has_more: bool, ctx: &'a HtmlPageContext, page_items: Vec, } #[derive(Template)] #[template(path = "login.html")] struct LoginTemplate<'a> { error: Option<&'a str>, ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "register.html")] struct RegisterTemplate<'a> { error: Option<&'a str>, ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "new_review.html")] struct NewReviewTemplate<'a> { error: Option<&'a str>, ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "activity_feed.html")] struct ActivityFeedTemplate<'a> { entries: &'a [FeedEntry], current_offset: u32, limit: u32, has_more: bool, ctx: &'a HtmlPageContext, page_items: Vec, } #[derive(Template)] #[template(path = "users.html")] struct UsersTemplate<'a> { users: &'a [UserSummary], ctx: &'a HtmlPageContext, } struct MonthlyRatingRow<'a> { rating: &'a MonthlyRating, bar_height_px: i64, } #[derive(Template)] #[template(path = "profile.html")] struct ProfileTemplate<'a> { ctx: &'a HtmlPageContext, profile_display_name: String, profile_user_id: uuid::Uuid, stats: &'a UserStats, view: &'a str, entries: Option<&'a Paginated>, current_offset: u32, has_more: bool, limit: u32, history: Option<&'a Vec>, trends: Option<&'a UserTrends>, monthly_rating_rows: Vec>, heatmap: Vec, page_items: Vec, is_own_profile: bool, error: Option, following_count: usize, } struct RemoteActorData { handle: String, display_name: Option, url: String, } #[derive(Template)] #[template(path = "following.html")] struct FollowingTemplate { ctx: HtmlPageContext, user_id: uuid::Uuid, actors: Vec, error: Option, } struct HeatmapCell { month_label: String, count: i64, alpha: f64, } #[allow(dead_code)] fn relative_time(dt: chrono::NaiveDateTime) -> String { let now = chrono::Utc::now().naive_utc(); let diff = now.signed_duration_since(dt); if diff.num_seconds() <= 0 { return "just now".to_string(); } let minutes = diff.num_minutes(); let hours = diff.num_hours(); let days = diff.num_days(); if minutes < 1 { return "just now".to_string(); } if minutes < 60 { return format!("{} min ago", minutes); } if hours < 24 { return format!("{} h ago", hours); } if days == 1 { return "yesterday".to_string(); } if days < 30 { return format!("{} days ago", days); } dt.format("%b %-d, %Y").to_string() } fn build_heatmap(history: &[MonthActivity]) -> Vec { let current_year = chrono::Utc::now().year(); let count_for = |m: &str| -> i64 { history.iter().find(|a| a.year_month == format!("{}-{}", current_year, m)) .map(|a| a.count) .unwrap_or(0) }; let months = [ ("01", "Jan"), ("02", "Feb"), ("03", "Mar"), ("04", "Apr"), ("05", "May"), ("06", "Jun"), ("07", "Jul"), ("08", "Aug"), ("09", "Sep"), ("10", "Oct"), ("11", "Nov"), ("12", "Dec"), ]; let counts: Vec = months.iter().map(|(m, _)| count_for(m)).collect(); let max = counts.iter().copied().max().unwrap_or(0).max(1); months.iter().zip(counts.iter()).map(|((_, label), &count)| { let alpha = if count == 0 { 0.05 } else { 0.15 + 0.75 * (count as f64 / max as f64) }; HeatmapCell { month_label: label.to_string(), count, alpha, } }).collect() } fn bar_height_px(avg_rating: f64) -> i64 { (avg_rating / 5.0 * 60.0) as i64 } pub struct AskamaHtmlRenderer; impl AskamaHtmlRenderer { pub fn new() -> Self { Self {} } } impl HtmlRenderer for AskamaHtmlRenderer { fn render_diary_page(&self, data: &Paginated, ctx: HtmlPageContext) -> Result { let has_more = (data.offset + data.limit) < data.total_count as u32; let (total_pages, current_page) = if data.limit > 0 { let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32; (tp, data.offset / data.limit) } else { (0, 0) }; DiaryTemplate { entries: &data.items, current_offset: data.offset, limit: data.limit, has_more, ctx: &ctx, page_items: build_page_items(total_pages, current_page), } .render() .map_err(|e| e.to_string()) } fn render_login_page(&self, data: LoginPageData<'_>) -> Result { LoginTemplate { error: data.error, ctx: &data.ctx, } .render() .map_err(|e| e.to_string()) } fn render_register_page(&self, data: RegisterPageData<'_>) -> Result { RegisterTemplate { error: data.error, ctx: &data.ctx, } .render() .map_err(|e| e.to_string()) } fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result { NewReviewTemplate { error: data.error, ctx: &data.ctx, } .render() .map_err(|e| e.to_string()) } fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result { let limit = data.limit; let total_pages = if limit > 0 { ((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32 } else { 0 }; let current_page = if limit > 0 { data.current_offset / limit } else { 0 }; ActivityFeedTemplate { entries: &data.entries.items, current_offset: data.current_offset, limit, has_more: data.has_more, ctx: &data.ctx, page_items: build_page_items(total_pages, current_page), } .render() .map_err(|e| e.to_string()) } fn render_users_page(&self, data: UsersPageData) -> Result { UsersTemplate { users: &data.users, ctx: &data.ctx, } .render() .map_err(|e| e.to_string()) } fn render_profile_page(&self, data: ProfilePageData) -> Result { let heatmap = data.history.as_deref() .map(|h| build_heatmap(h)) .unwrap_or_default(); let profile_display_name = data.profile_user_email .split('@').next().unwrap_or(&data.profile_user_email).to_string(); let monthly_rating_rows: Vec> = data.trends.as_ref() .map(|t| t.monthly_ratings.iter().map(|r| MonthlyRatingRow { bar_height_px: bar_height_px(r.avg_rating), rating: r, }).collect()) .unwrap_or_default(); let total_pages = data.entries.as_ref() .map(|e| if e.limit > 0 { ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32 } else { 0 }) .unwrap_or(0); let current_page = if data.limit > 0 { data.current_offset / data.limit } else { 0 }; ProfileTemplate { ctx: &data.ctx, profile_display_name, profile_user_id: data.profile_user_id, stats: &data.stats, view: &data.view, entries: data.entries.as_ref(), current_offset: data.current_offset, has_more: data.has_more, limit: data.limit, history: data.history.as_ref(), trends: data.trends.as_ref(), monthly_rating_rows, heatmap, page_items: build_page_items(total_pages, current_page), is_own_profile: data.is_own_profile, error: data.error, following_count: data.following_count, } .render() .map_err(|e| e.to_string()) } fn render_following_page(&self, data: FollowingPageData) -> Result { FollowingTemplate { ctx: data.ctx, user_id: data.user_id, actors: data.actors.into_iter().map(|a| RemoteActorData { handle: a.handle, display_name: a.display_name, url: a.url, }).collect(), error: data.error, } .render() .map_err(|e| e.to_string()) } }