use askama::Template; use chrono::Datelike; use application::ports::{ ActivityFeedPageData, HtmlPageContext, HtmlRenderer, LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData, }; use domain::models::{ DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, UserStats, UserSummary, UserTrends, collections::Paginated, }; #[derive(Template)] #[template(path = "diary.html")] struct DiaryTemplate<'a> { entries: &'a [DiaryEntry], current_offset: u32, limit: u32, has_more: bool, ctx: &'a HtmlPageContext, total_pages: u32, current_page: u32, } #[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, total_pages: u32, current_page: u32, } #[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, 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, total_pages: u32, current_page: u32, } 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 total_pages = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32; let current_page = data.offset / data.limit; (total_pages, current_page) } else { (0, 0) }; DiaryTemplate { entries: &data.items, current_offset: data.offset, limit: data.limit, has_more, ctx: &ctx, 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 total_count = data.entries.total_count; let limit = data.limit; let total_pages = ((total_count + limit as u64 - 1) / limit as u64) as u32; 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, 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| ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32) .unwrap_or(0); let current_page = if data.limit > 0 { data.current_offset / data.limit } else { 0 }; ProfileTemplate { ctx: &data.ctx, profile_display_name, 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, total_pages, current_page, } .render() .map_err(|e| e.to_string()) } }