diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 8e223a8..c57f194 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -425,3 +425,16 @@ pub enum ImportRowStatus { Duplicate, Invalid(String), } + +#[derive(Template)] +#[template(path = "wrapup.html")] +pub struct WrapUpPageTemplate<'a> { + pub ctx: &'a HtmlPageContext, + pub report: &'a domain::models::wrapup::WrapUpReport, + pub year_label: String, + pub watch_time_display: String, + pub rating_max: u32, + pub genre_max: u32, + pub rating_pcts: [f64; 5], + pub genre_pcts: Vec, +} diff --git a/crates/adapters/template-askama/templates/wrapup.html b/crates/adapters/template-askama/templates/wrapup.html new file mode 100644 index 0000000..182686c --- /dev/null +++ b/crates/adapters/template-askama/templates/wrapup.html @@ -0,0 +1,200 @@ +{% extends "base.html" %} +{% block content %} +
+ +
+

{{ year_label }}

+
{{ report.total_movies }}
+
movies watched
+ {% if report.total_watch_time_minutes > 0 %} +
{{ watch_time_display }} total watch time
+ {% endif %} +
+ +
+

Ratings

+ {% if let Some(avg) = report.avg_rating %} +
{{ avg|fmt("{:.1}") }}
+
average rating
+ {% endif %} +
+ {% for i in 0..5 %} +
+ {{ i + 1 }}★ +
+
+
+ {{ report.rating_distribution[i] }} +
+ {% endfor %} +
+ {% if let Some(month) = report.busiest_month %} +
Busiest month: {{ month }}
+ {% endif %} + {% if let Some(day) = report.busiest_day_of_week %} +
Favorite day: {{ day }}
+ {% endif %} +
+ + {% if !report.top_directors.is_empty() %} +
+

Top Directors

+
{{ report.director_diversity }} unique directors
+ {% for d in report.top_directors.iter().take(5) %} +
+ {{ d.name }} + {{ d.count }} films · {{ d.avg_rating|fmt("{:.1}") }}★ +
+ {% endfor %} +
+ {% endif %} + + {% if !report.top_actors.is_empty() %} +
+

Top Actors

+
{{ report.actor_diversity }} unique actors
+ {% for a in report.top_actors.iter().take(5) %} +
+ {{ a.name }} + {{ a.count }} films · {{ a.avg_rating|fmt("{:.1}") }}★ +
+ {% endfor %} +
+ {% endif %} + + {% if !report.top_genres.is_empty() %} +
+

Genre Breakdown

+
{{ report.genre_diversity }} genres explored
+ {% for g in report.top_genres.iter().take(8).enumerate() %} +
+ {{ g.1.genre }} + {{ g.1.count }} +
+
+
+
+ {% endfor %} + {% if let Some(best) = report.highest_rated_genre %} +
Highest rated: {{ best }}
+ {% endif %} + {% if let Some(worst) = report.lowest_rated_genre %} +
Lowest rated: {{ worst }}
+ {% endif %} +
+ {% endif %} + +
+

Highlights

+
+ {% if let Some(m) = report.highest_rated_movie %} +
+
Highest Rated
+ {% if let Some(p) = m.poster_path %} + {{ m.title }} + {% endif %} +
{{ m.title }}
+
{{ m.year }}
+
+ {% endif %} + {% if let Some(m) = report.lowest_rated_movie %} +
+
Lowest Rated
+ {% if let Some(p) = m.poster_path %} + {{ m.title }} + {% endif %} +
{{ m.title }}
+
{{ m.year }}
+
+ {% endif %} + {% if let Some(m) = report.oldest_movie %} +
+
Oldest
+ {% if let Some(p) = m.poster_path %} + {{ m.title }} + {% endif %} +
{{ m.title }}
+
{{ m.year }}
+
+ {% endif %} + {% if let Some(m) = report.newest_movie %} +
+
Newest
+ {% if let Some(p) = m.poster_path %} + {{ m.title }} + {% endif %} +
{{ m.title }}
+
{{ m.year }}
+
+ {% endif %} + {% if let Some(m) = report.longest_movie %} +
+
Longest
+ {% if let Some(p) = m.poster_path %} + {{ m.title }} + {% endif %} +
{{ m.title }}
+ {% if let Some(rt) = m.runtime_minutes %} +
{{ rt }} min
+ {% endif %} +
+ {% endif %} + {% if let Some(m) = report.shortest_movie %} +
+
Shortest
+ {% if let Some(p) = m.poster_path %} + {{ m.title }} + {% endif %} +
{{ m.title }}
+ {% if let Some(rt) = m.runtime_minutes %} +
{{ rt }} min
+ {% endif %} +
+ {% endif %} + {% if let Some(m) = report.first_movie_of_period %} +
+
First Watched
+ {% if let Some(p) = m.poster_path %} + {{ m.title }} + {% endif %} +
{{ m.title }}
+
{{ m.year }}
+
+ {% endif %} + {% if let Some(m) = report.last_movie_of_period %} +
+
Last Watched
+ {% if let Some(p) = m.poster_path %} + {{ m.title }} + {% endif %} +
{{ m.title }}
+
{{ m.year }}
+
+ {% endif %} +
+
+ + {% if report.total_rewatches > 0 %} +
+

Rewatches

+
{{ report.total_rewatches }}
+
movies rewatched
+ {% if let Some(m) = report.most_rewatched_movie %} +
Most rewatched: {{ m.title }} ({{ m.year }})
+ {% endif %} +
+ {% endif %} + + {% if !report.poster_paths.is_empty() %} +
+

Your Year in Posters

+
+ {% for path in report.poster_paths.iter() %} + + {% endfor %} +
+
+ {% endif %} + +
+{% endblock %} diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs index fa1acd0..e6e8620 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/presentation/src/handlers/wrapup.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Path, State}, + extract::{Extension, Path, State}, http::StatusCode, response::IntoResponse, }; @@ -13,10 +13,16 @@ use application::wrapup::{ list_wrapups::{self, ListWrapUpsQuery}, }; use domain::errors::DomainError; -use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; +use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus}; use domain::value_objects::WrapUpId; -use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; +use crate::{ + csrf::CsrfToken, + errors::ApiError, + extractors::{AuthenticatedUser, OptionalCookieUser}, + render::render_page, + state::AppState, +}; use api_types::wrapup::{ GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse, }; @@ -145,3 +151,122 @@ pub async fn get_report( Err(e) => crate::errors::domain_error_response(e), } } + +// ── HTML handlers ─────────────────────────────────────────────────────────── + +fn format_watch_time(minutes: u32) -> String { + let h = minutes / 60; + let m = minutes % 60; + if h > 0 && m > 0 { + format!("{}h {}m", h, m) + } else if h > 0 { + format!("{}h", h) + } else { + format!("{}m", m) + } +} + +fn render_wrapup( + report: &WrapUpReport, + year: i32, + ctx: &application::ports::HtmlPageContext, +) -> axum::response::Response { + let rating_max = report.rating_distribution.iter().copied().max().unwrap_or(1).max(1); + let rating_pcts: [f64; 5] = std::array::from_fn(|i| { + report.rating_distribution[i] as f64 / rating_max as f64 * 100.0 + }); + let genre_max = report.top_genres.first().map(|g| g.count).unwrap_or(1).max(1); + let genre_pcts: Vec = report + .top_genres + .iter() + .take(8) + .map(|g| g.count as f64 / genre_max as f64 * 100.0) + .collect(); + let tmpl = template_askama::WrapUpPageTemplate { + ctx, + report, + year_label: year.to_string(), + watch_time_display: format_watch_time(report.total_watch_time_minutes), + rating_max, + genre_max, + rating_pcts, + genre_pcts, + }; + render_page(tmpl) +} + +pub async fn get_user_wrapup_html( + OptionalCookieUser(viewer): OptionalCookieUser, + State(state): State, + Path((user_id, year)): Path<(Uuid, i32)>, + Extension(csrf): Extension, +) -> impl IntoResponse { + let start = match NaiveDate::from_ymd_opt(year, 1, 1) { + Some(d) => d, + None => return StatusCode::BAD_REQUEST.into_response(), + }; + let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) { + Some(d) => d, + None => return StatusCode::BAD_REQUEST.into_response(), + }; + + let record = match state + .app_ctx + .repos + .wrapup_repo + .find_existing(Some(user_id), start, end) + .await + { + Ok(Some(r)) if r.status == WrapUpStatus::Ready => r, + _ => return StatusCode::NOT_FOUND.into_response(), + }; + + let report: WrapUpReport = match &record.report_json { + Some(json) => match serde_json::from_str(json) { + Ok(r) => r, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, + None => return StatusCode::NOT_FOUND.into_response(), + }; + + let ctx = super::html::build_page_context(&state, viewer, csrf.0).await; + render_wrapup(&report, year, &ctx) +} + +pub async fn get_global_wrapup_html( + OptionalCookieUser(viewer): OptionalCookieUser, + State(state): State, + Path(year): Path, + Extension(csrf): Extension, +) -> impl IntoResponse { + let start = match NaiveDate::from_ymd_opt(year, 1, 1) { + Some(d) => d, + None => return StatusCode::BAD_REQUEST.into_response(), + }; + let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) { + Some(d) => d, + None => return StatusCode::BAD_REQUEST.into_response(), + }; + + let record = match state + .app_ctx + .repos + .wrapup_repo + .find_existing(None, start, end) + .await + { + Ok(Some(r)) if r.status == WrapUpStatus::Ready => r, + _ => return StatusCode::NOT_FOUND.into_response(), + }; + + let report: WrapUpReport = match &record.report_json { + Some(json) => match serde_json::from_str(json) { + Ok(r) => r, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, + None => return StatusCode::NOT_FOUND.into_response(), + }; + + let ctx = super::html::build_page_context(&state, viewer, csrf.0).await; + render_wrapup(&report, year, &ctx) +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 7f2c043..2b6d145 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -163,6 +163,14 @@ fn html_routes(rate_limit: u64) -> Router { .route( "/watch-queue/{id}/dismiss", routing::post(handlers::html::post_dismiss_single), + ) + .route( + "/wrapups/{user_id}/{year}", + routing::get(handlers::wrapup::get_user_wrapup_html), + ) + .route( + "/wrapups/global/{year}", + routing::get(handlers::wrapup::get_global_wrapup_html), ); #[cfg(feature = "federation")] diff --git a/static/style.css b/static/style.css index 1d400e2..2211553 100644 --- a/static/style.css +++ b/static/style.css @@ -1184,3 +1184,46 @@ form button[type="submit"]:hover { .movie-title-link:hover { text-decoration: underline; } + +/* ── Wrap-up ─────────────────────────────────────────────────────────── */ +.wu-container { max-width: 600px; margin: 0 auto; scroll-snap-type: y proximity; } +.wu-section { + min-height: 80vh; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + text-align: center; + padding: 2rem; + scroll-snap-align: start; + animation: wu-fade-in 0.6s ease-out both; +} +.wu-hero { min-height: 60vh; } +.wu-year { font-size: 2.4rem; opacity: 0.5; margin-bottom: 0.5rem; } +.wu-big-number { font-size: 5rem; font-weight: 800; color: var(--primary); line-height: 1.1; } +.wu-subtitle { font-size: 1.4rem; opacity: 0.8; } +.wu-detail { font-size: 1rem; opacity: 0.6; margin-top: 0.5rem; } +.wu-section h2 { font-size: 1.8rem; margin-bottom: 1.5rem; color: var(--primary); } +.wu-stat-row { display: flex; justify-content: space-between; width: 100%; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.1); } +.wu-stat-name { font-weight: 600; } +.wu-stat-count { opacity: 0.7; } +.wu-rating-bars { width: 100%; max-width: 400px; margin-top: 1.5rem; } +.wu-rating-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } +.wu-star-label { width: 2rem; text-align: right; font-size: 0.9rem; opacity: 0.7; } +.wu-bar-track { flex: 1; height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; } +.wu-bar { height: 8px; background: var(--primary); border-radius: 4px; min-width: 2px; transition: width 0.4s ease; } +.wu-bar-count { width: 2rem; font-size: 0.85rem; opacity: 0.6; } +.wu-highlight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; width: 100%; } +.wu-highlight-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 1rem; text-align: left; } +.wu-highlight-label { font-size: 0.8rem; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.05em; } +.wu-highlight-title { font-weight: 700; margin-top: 0.25rem; } +.wu-highlight-poster { width: 100%; border-radius: 6px; aspect-ratio: 2/3; object-fit: cover; margin: 0.5rem 0; } +.wu-poster-mosaic { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 4px; width: 100%; } +.wu-poster-mosaic img { width: 100%; border-radius: 4px; aspect-ratio: 2/3; object-fit: cover; } +@keyframes wu-fade-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } } +.wu-section:nth-child(2) { animation-delay: 0.1s; } +.wu-section:nth-child(3) { animation-delay: 0.2s; } +.wu-section:nth-child(4) { animation-delay: 0.3s; } +.wu-section:nth-child(5) { animation-delay: 0.4s; } +@media (max-width: 480px) { + .wu-big-number { font-size: 3.5rem; } + .wu-highlight-grid { grid-template-columns: 1fr; } +}