summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 14:34:01 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 14:34:01 -0700
commita3df69ff5218cee132a6def9d860cd0276cc0cd4 (patch)
tree2d8bf71b98a606b2f1194ce608c23a8e17d8cb6c
parente708bec6cd76c2686de4158dde4d04f72a3c300d (diff)
Add year review and inline diary ratings
-rw-r--r--routers/films.py1
-rw-r--r--routers/stats.py207
-rw-r--r--static/app.js78
-rw-r--r--static/styles.css221
-rw-r--r--templates/_film_card.html14
-rw-r--r--templates/detail.html80
-rw-r--r--templates/stats.html9
-rw-r--r--templates/year_review.html175
8 files changed, 762 insertions, 23 deletions
diff --git a/routers/films.py b/routers/films.py
index 0a6491f..fc087b1 100644
--- a/routers/films.py
+++ b/routers/films.py
@@ -6,7 +6,6 @@ from types import SimpleNamespace
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
-from fastapi.responses import JSONResponse
from sqlalchemy import func
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
diff --git a/routers/stats.py b/routers/stats.py
index 78e9621..0604646 100644
--- a/routers/stats.py
+++ b/routers/stats.py
@@ -1,4 +1,5 @@
from collections import Counter
+from calendar import month_name
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request
@@ -112,6 +113,190 @@ def _diary_films(db: Session) -> list[Film]:
)
+def _available_years(films: list[Film]) -> list[int]:
+ years = {film.date_watched.year for film in films if film.date_watched}
+ return sorted(years, reverse=True)
+
+
+def _format_film_excerpt(notes: str | None, limit: int = 140) -> str | None:
+ if not notes:
+ return None
+ excerpt = " ".join(notes.split())
+ if len(excerpt) <= limit:
+ return excerpt
+ return excerpt[: limit - 1].rstrip() + "..."
+
+
+def _film_highlight_payload(film: Film) -> dict:
+ return {
+ "id": film.id,
+ "title": film.title,
+ "poster_url": film.poster_url,
+ "director": film.director,
+ "year": film.year,
+ "date_watched": film.date_watched.isoformat() if film.date_watched else None,
+ "stars": film.stars,
+ "notes_excerpt": _format_film_excerpt(film.notes),
+ "runtime": film.runtime,
+ "country": film.country,
+ "language": film.language,
+ }
+
+
+def _select_year(selected_year: int | None, available_years: list[int]) -> int | None:
+ if selected_year is not None:
+ return selected_year
+ if available_years:
+ return available_years[0]
+ return None
+
+
+def _films_for_year(films: list[Film], year: int) -> list[Film]:
+ return [
+ film
+ for film in films
+ if film.date_watched and film.date_watched.year == year
+ ]
+
+
+def _year_review_payload(db: Session, year: int | None) -> dict:
+ diary_films = _diary_films(db)
+ available_years = _available_years(diary_films)
+ selected_year = _select_year(year, available_years)
+ if selected_year is None:
+ return {
+ "selected_year": date.today().year if year is None else year,
+ "available_years": [],
+ "total_watched": 0,
+ "average_stars": 0,
+ "most_watched_directors": [],
+ "star_distribution": [{"stars": stars, "count": 0} for stars in (0, 1, 2, 3)],
+ "films_per_month": [{"month": month_name[index], "count": 0} for index in range(1, 13)],
+ "rewatch_rate": {"rewatched": 0, "total_watched": 0, "rate": 0},
+ "watched_with_breakdown": [],
+ "top_director": None,
+ "top_month": None,
+ "highlight_films": {
+ "highest_rated": [],
+ "first_watch": None,
+ "last_watch": None,
+ "most_rewatched": None,
+ },
+ }
+
+ year_films = _films_for_year(diary_films, selected_year)
+ countries = Counter()
+ directors = Counter()
+ star_counts = Counter({0: 0, 1: 0, 2: 0, 3: 0})
+ months = Counter({month_index: 0 for month_index in range(1, 13)})
+ watched_with = Counter()
+
+ for film in year_films:
+ countries.update(split_country_names(film.country))
+ directors.update(split_credit_names(film.director))
+ stars = film.stars if film.stars in {0, 1, 2, 3} else 0
+ star_counts[stars] += 1
+ if film.date_watched:
+ months[film.date_watched.month] += 1
+ companions = split_credit_names(film.watched_with)
+ if companions:
+ watched_with.update(companions)
+ else:
+ watched_with["solo"] += 1
+
+ total_watched = len(year_films)
+ rewatched = sum(1 for film in year_films if film.rewatch or film.rewatch_count > 0)
+ average_stars = round(sum(film.stars for film in year_films) / total_watched, 1) if total_watched else 0
+
+ top_director = None
+ if directors:
+ top_director_name, top_director_count = sorted(directors.items(), key=lambda item: (-item[1], item[0]))[0]
+ top_director = {"director": top_director_name, "count": top_director_count}
+
+ top_month = None
+ if total_watched:
+ top_month_index, top_month_count = sorted(months.items(), key=lambda item: (-item[1], item[0]))[0]
+ top_month = {
+ "month": month_name[top_month_index],
+ "count": top_month_count,
+ }
+
+ year_films_sorted = sorted(
+ year_films,
+ key=lambda film: (
+ -(film.stars or 0),
+ -(film.date_watched.toordinal()) if film.date_watched else 0,
+ film.title.casefold(),
+ film.id,
+ ),
+ )
+ highest_rated = [_film_highlight_payload(film) for film in year_films_sorted[:4]]
+
+ first_watch = None
+ last_watch = None
+ if year_films:
+ first_watch_film = min(
+ year_films,
+ key=lambda film: (film.date_watched or date.max, film.id),
+ )
+ last_watch_film = max(
+ year_films,
+ key=lambda film: (film.date_watched or date.min, film.id),
+ )
+ first_watch = _film_highlight_payload(first_watch_film)
+ last_watch = _film_highlight_payload(last_watch_film)
+
+ most_rewatched = None
+ rewatched_candidates = [
+ film
+ for film in year_films
+ if film.rewatch_count > 0 or film.rewatch
+ ]
+ if rewatched_candidates:
+ rewatched_candidates.sort(
+ key=lambda film: (
+ -film.rewatch_count,
+ -(film.date_watched.toordinal()) if film.date_watched else 0,
+ film.title.casefold(),
+ film.id,
+ )
+ )
+ most_rewatched = _film_highlight_payload(rewatched_candidates[0])
+
+ return {
+ "selected_year": selected_year,
+ "available_years": available_years,
+ "total_watched": total_watched,
+ "average_stars": average_stars,
+ "films_per_month": [
+ {"month": month_name[index], "count": months[index]}
+ for index in range(1, 13)
+ ],
+ "star_distribution": [{"stars": stars, "count": star_counts[stars]} for stars in (0, 1, 2, 3)],
+ "most_watched_directors": [
+ {"director": director, "count": count}
+ for director, count in sorted(directors.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "watched_with_breakdown": [
+ {"watched_with": watched_with_value, "count": count}
+ for watched_with_value, count in sorted(watched_with.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "rewatch_rate": {
+ "rewatched": rewatched,
+ "total_watched": total_watched,
+ "rate": round(rewatched / total_watched, 4) if total_watched else 0,
+ },
+ "top_director": top_director,
+ "top_month": top_month,
+ "highlight_films": {
+ "highest_rated": highest_rated,
+ "first_watch": first_watch,
+ "last_watch": last_watch,
+ "most_rewatched": most_rewatched,
+ },
+ }
+
+
@router.get("/stats")
def stats_page(request: Request):
return templates.TemplateResponse(
@@ -124,3 +309,25 @@ def stats_page(request: Request):
@router.get("/stats/data")
def stats_data(db: Session = Depends(get_db)):
return _build_stats_payload(_diary_films(db))
+
+
+@router.get("/stats/year-in-review")
+def year_in_review_page(
+ request: Request,
+ year: int | None = None,
+ db: Session = Depends(get_db),
+):
+ review = _year_review_payload(db, year)
+ return templates.TemplateResponse(
+ request=request,
+ name="year_review.html",
+ context={"request": request, "active_page": "stats", **review},
+ )
+
+
+@router.get("/stats/year-in-review/data")
+def year_in_review_data(
+ year: int | None = None,
+ db: Session = Depends(get_db),
+):
+ return _year_review_payload(db, year)
diff --git a/static/app.js b/static/app.js
index 01b1d79..e2213f3 100644
--- a/static/app.js
+++ b/static/app.js
@@ -2,6 +2,37 @@ const tmdbQuery = document.querySelector("#tmdb-query");
const tmdbButton = document.querySelector("#tmdb-search");
const tmdbResults = document.querySelector("#tmdb-results");
+const syncStarControl = (control, stars) => {
+ control.dataset.currentStars = String(stars);
+ control.dataset.previewStars = "";
+ control.querySelectorAll(".star-button").forEach((button) => {
+ const value = Number(button.dataset.stars || 0);
+ const active = stars >= value;
+ button.classList.toggle("is-active", active);
+ button.classList.remove("is-preview");
+ button.setAttribute("aria-pressed", active ? "true" : "false");
+ });
+};
+
+const previewStarControl = (control, stars) => {
+ control.dataset.previewStars = String(stars);
+ control.querySelectorAll(".star-button").forEach((button) => {
+ const value = Number(button.dataset.stars || 0);
+ const previewed = stars >= value;
+ button.classList.toggle("is-preview", previewed);
+ });
+};
+
+const clearStarPreview = (control) => {
+ control.dataset.previewStars = "";
+ const stars = Number(control.dataset.currentStars || 0);
+ control.querySelectorAll(".star-button").forEach((button) => {
+ const value = Number(button.dataset.stars || 0);
+ button.classList.toggle("is-preview", false);
+ button.classList.toggle("is-active", stars >= value);
+ });
+};
+
const setValue = (selector, value) => {
const element = document.querySelector(selector);
if (element && value !== null && value !== undefined) {
@@ -129,3 +160,50 @@ document.querySelectorAll("form[data-confirm]").forEach((form) => {
}
});
});
+
+document.addEventListener("click", async (event) => {
+ const button = event.target.closest(".star-button");
+ if (!button) return;
+
+ const control = button.closest(".star-control");
+ if (!control || !control.dataset.filmId) return;
+
+ const stars = Number(button.dataset.stars || 0);
+ const filmId = control.dataset.filmId;
+
+ button.disabled = true;
+ try {
+ const response = await fetch(`/films/${filmId}/stars`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ stars }),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ return;
+ }
+
+ syncStarControl(control, Number(data.stars || 0));
+ } catch (error) {
+ console.error("Failed to update stars", error);
+ } finally {
+ button.disabled = false;
+ }
+});
+
+document.querySelectorAll(".star-control").forEach((control) => {
+ syncStarControl(control, Number(control.dataset.currentStars || 0));
+ control.addEventListener("pointerleave", () => clearStarPreview(control));
+ control.addEventListener("focusout", (event) => {
+ if (!control.contains(event.relatedTarget)) {
+ clearStarPreview(control);
+ }
+ });
+ control.querySelectorAll(".star-button").forEach((button) => {
+ button.addEventListener("pointerenter", () => previewStarControl(control, Number(button.dataset.stars || 0)));
+ button.addEventListener("focus", () => previewStarControl(control, Number(button.dataset.stars || 0)));
+ });
+});
diff --git a/static/styles.css b/static/styles.css
index f7c6fbc..b9ab7dd 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -278,6 +278,57 @@ h2 {
font-weight: 800;
}
+.star-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 0;
+ width: auto;
+ margin-left: auto;
+}
+
+.star-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.15em;
+ min-height: 0;
+ padding: 0;
+ margin: 0;
+ border: 0 !important;
+ border-radius: 0;
+ background: none !important;
+ box-shadow: none !important;
+ appearance: none;
+ color: var(--subtle);
+ font-size: 1.1rem;
+ font-weight: 700;
+ line-height: 1;
+ opacity: 0.6;
+}
+
+.star-button.is-active {
+ color: var(--accent);
+ opacity: 1;
+}
+
+.star-button.is-preview {
+ color: var(--accent-strong);
+ opacity: 1;
+}
+
+.star-button:hover {
+ color: var(--accent-strong);
+ opacity: 1;
+ background: none !important;
+}
+
+.star-button:focus-visible {
+ outline: none;
+ color: var(--accent-strong);
+ opacity: 1;
+ background: none !important;
+}
+
.meta-row,
.detail-meta {
display: flex;
@@ -380,6 +431,27 @@ h2 {
width: 100%;
}
+.detail-poster {
+ display: grid;
+ gap: 16px;
+ align-content: start;
+}
+
+.detail-aside-meta {
+ display: grid;
+ gap: 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 16px;
+}
+
+.detail-aside-meta strong {
+ display: block;
+ margin-top: 4px;
+ color: var(--text);
+}
+
.detail-body h1 {
margin-bottom: 10px;
}
@@ -392,6 +464,45 @@ h2 {
margin-top: 18px;
}
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ margin-top: 18px;
+}
+
+.detail-panel {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 16px;
+}
+
+.detail-panel .eyebrow {
+ margin-bottom: 10px;
+}
+
+.detail-tagline {
+ margin: 0 0 12px;
+ color: var(--accent-strong);
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 1.08rem;
+}
+
+.detail-overview {
+ margin: 0;
+ color: #e3dacd;
+}
+
+.detail-cast {
+ margin-top: 16px;
+}
+
+.detail-cast p {
+ margin: 6px 0 0;
+ color: var(--muted);
+}
+
.notes-body {
margin-top: 28px;
max-width: 68ch;
@@ -731,6 +842,101 @@ textarea:focus {
color: var(--muted);
}
+.review-hero {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 48px 0 22px;
+}
+
+.review-intro {
+ max-width: 60ch;
+ color: var(--muted);
+}
+
+.year-picker {
+ display: grid;
+ gap: 8px;
+ min-width: 180px;
+}
+
+.year-picker label {
+ margin: 0;
+}
+
+.review-metrics {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 14px;
+ margin-bottom: 18px;
+}
+
+.review-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.review-panel {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+ padding: 18px;
+}
+
+.review-panel-wide {
+ grid-column: 1 / -1;
+}
+
+.year-bars {
+ display: grid;
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.year-bar-row {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+}
+
+.year-bar-track {
+ height: 10px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: var(--panel-soft);
+}
+
+.year-bar-fill {
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, rgba(240, 184, 77, 0.35), var(--accent));
+}
+
+.highlight-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 14px;
+ margin-top: 12px;
+}
+
+.highlight-card {
+ display: grid;
+ grid-template-columns: 72px minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.highlight-card h2 {
+ margin-top: 0;
+}
+
+.highlight-meta {
+ color: var(--subtle);
+}
+
@media (max-width: 760px) {
.shell {
width: min(100% - 24px, 1120px);
@@ -783,6 +989,21 @@ textarea:focus {
flex-direction: column;
}
+ .review-hero {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .review-metrics,
+ .review-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .highlight-card {
+ grid-template-columns: 1fr;
+ align-items: start;
+ }
+
.heatmap-months {
margin-left: 38px;
}
diff --git a/templates/_film_card.html b/templates/_film_card.html
index 9fcd89b..7147a1e 100644
--- a/templates/_film_card.html
+++ b/templates/_film_card.html
@@ -21,7 +21,19 @@
{% endif %}
</p>
</div>
- {% if film.stars %}
+ {% if film.shelf == 'diary' %}
+ <div class="star-control" role="group" aria-label="Rate film" data-film-id="{{ film.id }}" data-current-stars="{{ film.stars }}">
+ {% for value in range(1, 4) %}
+ <button
+ type="button"
+ class="star-button {% if film.stars >= value %}is-active{% endif %}"
+ data-stars="{{ value }}"
+ aria-label="{{ value }} star{% if value > 1 %}s{% endif %}"
+ aria-pressed="{% if film.stars >= value %}true{% else %}false{% endif %}"
+ >✦</button>
+ {% endfor %}
+ </div>
+ {% elif film.stars %}
<span class="rating">{% for _ in range(film.stars) %}✦{% endfor %}</span>
{% endif %}
</div>
diff --git a/templates/detail.html b/templates/detail.html
index b937c6b..df3f96a 100644
--- a/templates/detail.html
+++ b/templates/detail.html
@@ -12,6 +12,20 @@
<span>{{ film.title[:1] }}</span>
{% endif %}
</div>
+ <div class="detail-aside-meta">
+ <div>
+ <span class="summary-label">Shelf</span>
+ <strong>{{ film.shelf|title }}</strong>
+ </div>
+ <div>
+ <span class="summary-label">Watched</span>
+ <strong>{% if film.date_watched %}{{ film.date_watched }}{% else %}Not set{% endif %}</strong>
+ </div>
+ <div>
+ <span class="summary-label">Stars</span>
+ <strong>{% if film.stars %}{% for _ in range(film.stars) %}✦{% endfor %}{% else %}Unstarred{% endif %}</strong>
+ </div>
+ </div>
</aside>
<section class="detail-body">
@@ -31,29 +45,57 @@
{% endif %}
</p>
- <div class="detail-meta">
- {% if film.stars %}<span class="rating">{% for _ in range(film.stars) %}✦{% endfor %}</span>{% endif %}
- {% if film.date_watched %}<span>{{ film.date_watched }}</span>{% endif %}
- {% if film.runtime %}<span>{{ film.runtime }} min</span>{% endif %}
- {% if film.rewatch %}<span>Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}</span>{% endif %}
- {% if film.country %}<span>{{ film.country }}</span>{% endif %}
- {% if film.language %}<span>{{ film.language }}</span>{% endif %}
- </div>
+ <section class="detail-grid">
+ <article class="detail-panel">
+ <p class="eyebrow">Watch log</p>
+ <div class="detail-meta">
+ {% if film.date_watched %}<span>{{ film.date_watched }}</span>{% endif %}
+ {% if film.runtime %}<span>{{ film.runtime }} min</span>{% endif %}
+ {% if film.rewatch %}<span>Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}</span>{% endif %}
+ {% if film.watched_with %}<span>With {{ film.watched_with }}</span>{% endif %}
+ {% if film.context %}<span>{{ film.context }}</span>{% endif %}
+ {% if film.how_found %}<span>{{ film.how_found }}</span>{% endif %}
+ </div>
+ </article>
- {% if film.context or film.how_found or film.watched_with %}
- <div class="tag-row detail-tags">
- {% if film.context %}<span>{{ film.context }}</span>{% endif %}
- {% if film.how_found %}<span>{{ film.how_found }}</span>{% endif %}
- {% if film.watched_with %}<span>With {{ film.watched_with }}</span>{% endif %}
- </div>
- {% endif %}
+ <article class="detail-panel">
+ <p class="eyebrow">Production</p>
+ <div class="detail-meta">
+ {% if film.country %}<span>{{ film.country }}</span>{% endif %}
+ {% if film.language %}<span>{{ film.language }}</span>{% endif %}
+ {% if film.year %}<span>{{ film.year }}</span>{% endif %}
+ {% if film.tmdb_id %}<span>TMDB {{ film.tmdb_id }}</span>{% endif %}
+ </div>
+ </article>
+ </section>
- {% if film.notes %}
- <div class="notes-body">
- {{ film.notes }}
- </div>
+ {% if tmdb_context %}
+ <section class="detail-panel">
+ <p class="eyebrow">Summary</p>
+ {% if tmdb_context.tagline %}
+ <p class="detail-tagline">{{ tmdb_context.tagline }}</p>
+ {% endif %}
+ {% if tmdb_context.overview %}
+ <p class="detail-overview">{{ tmdb_context.overview }}</p>
+ {% endif %}
+ {% if tmdb_context.cast %}
+ <div class="detail-cast">
+ <span class="summary-label">Cast</span>
+ <p>{{ tmdb_context.cast|join(", ") }}</p>
+ </div>
+ {% endif %}
+ </section>
{% endif %}
+ <section class="detail-panel">
+ <p class="eyebrow">Notes</p>
+ {% if film.notes %}
+ <div class="notes-body">{{ film.notes }}</div>
+ {% else %}
+ <p class="muted">No notes saved.</p>
+ {% endif %}
+ </section>
+
<div class="detail-actions">
<a class="secondary-button" href="/films/{{ film.id }}/edit">Edit</a>
{% if film.shelf == 'queue' %}
diff --git a/templates/stats.html b/templates/stats.html
index 7b54634..2983418 100644
--- a/templates/stats.html
+++ b/templates/stats.html
@@ -4,8 +4,13 @@
{% block content %}
<section class="page-heading">
- <p class="eyebrow">Stats</p>
- <h1>Watching patterns</h1>
+ <div class="page-heading-row">
+ <div>
+ <p class="eyebrow">Stats</p>
+ <h1>Watching patterns</h1>
+ </div>
+ <a class="button-link" href="/stats/year-in-review">Year in review</a>
+ </div>
</section>
<section class="stats-layout">
diff --git a/templates/year_review.html b/templates/year_review.html
new file mode 100644
index 0000000..5339c34
--- /dev/null
+++ b/templates/year_review.html
@@ -0,0 +1,175 @@
+{% extends "base.html" %}
+
+{% block title %}{{ selected_year }} Year in Review · Lumière{% endblock %}
+
+{% block content %}
+ <section class="review-hero">
+ <div>
+ <p class="eyebrow">Year in Review</p>
+ <h1>{{ selected_year }}</h1>
+ <p class="review-intro">A snapshot of the films you logged that year.</p>
+ </div>
+
+ <form class="year-picker" method="get" action="/stats/year-in-review">
+ <label for="year">Year</label>
+ <select id="year" name="year" onchange="this.form.submit()">
+ {% for option in available_years %}
+ <option value="{{ option }}" {% if option == selected_year %}selected{% endif %}>{{ option }}</option>
+ {% endfor %}
+ </select>
+ </form>
+ </section>
+
+ {% if total_watched == 0 %}
+ <section class="empty-state">
+ <p class="eyebrow">No entries</p>
+ <h2>No diary films for this year</h2>
+ <p>Pick another year or add more diary entries.</p>
+ </section>
+ {% else %}
+ <section class="review-metrics">
+ <article class="summary-card">
+ <span class="summary-label">Films watched</span>
+ <strong>{{ total_watched }}</strong>
+ </article>
+ <article class="summary-card">
+ <span class="summary-label">Average stars</span>
+ <strong>{{ "%.1f"|format(average_stars) }}</strong>
+ </article>
+ <article class="summary-card">
+ <span class="summary-label">Rewatch rate</span>
+ <strong>{{ (rewatch_rate.rate * 100)|round(0) }}%</strong>
+ </article>
+ <article class="summary-card">
+ <span class="summary-label">Top director</span>
+ <strong>{% if top_director %}{{ top_director.director }}{% else %}—{% endif %}</strong>
+ </article>
+ <article class="summary-card">
+ <span class="summary-label">Top month</span>
+ <strong>{% if top_month %}{{ top_month.month }}{% else %}—{% endif %}</strong>
+ </article>
+ </section>
+
+ <section class="review-grid">
+ <article class="review-panel review-panel-wide">
+ <p class="eyebrow">Monthly activity</p>
+ <div class="year-bars">
+ {% set max_month = films_per_month | map(attribute='count') | max if films_per_month else 1 %}
+ {% for item in films_per_month %}
+ <div class="year-bar-row">
+ <span>{{ item.month[:3] }}</span>
+ <div class="year-bar-track">
+ <div class="year-bar-fill" style="width: {{ (item.count / max_month * 100) if max_month else 0 }}%"></div>
+ </div>
+ <strong>{{ item.count }}</strong>
+ </div>
+ {% endfor %}
+ </div>
+ </article>
+
+ <article class="review-panel">
+ <p class="eyebrow">Stars</p>
+ <div class="stats-bars">
+ {% set max_stars = star_distribution | map(attribute='count') | max if star_distribution else 1 %}
+ {% for item in star_distribution %}
+ <div class="stats-bar-row">
+ <span>{{ item.stars }} star</span>
+ <div class="stats-bar-track"><div class="stats-bar-fill" style="width: {{ (item.count / max_stars * 100) if max_stars else 0 }}%"></div></div>
+ <strong>{{ item.count }}</strong>
+ </div>
+ {% endfor %}
+ </div>
+ </article>
+
+ <article class="review-panel">
+ <p class="eyebrow">Companions</p>
+ <ol class="stats-list">
+ {% for item in watched_with_breakdown %}
+ <li><span>{{ item.watched_with }}</span><strong>{{ item.count }}</strong></li>
+ {% endfor %}
+ </ol>
+ </article>
+ </section>
+
+ <section class="review-panel">
+ <p class="eyebrow">Highlights</p>
+ <div class="highlight-grid">
+ {% if highlight_films.highest_rated %}
+ {% for film in highlight_films.highest_rated %}
+ <article class="highlight-card">
+ <a class="poster-frame" href="/films/{{ film.id }}">
+ {% if film.poster_url %}
+ <img src="{{ film.poster_url }}" alt="{{ film.title }} poster">
+ {% else %}
+ <span>{{ film.title[:1] }}</span>
+ {% endif %}
+ </a>
+ <div>
+ <h2><a href="/films/{{ film.id }}">{{ film.title }}</a></h2>
+ <p class="muted">{{ film.director or "Unknown director" }}</p>
+ <p class="highlight-meta">
+ {% if film.date_watched %}{{ film.date_watched }}{% endif %}
+ {% if film.stars %} · {% for _ in range(film.stars) %}✦{% endfor %}{% endif %}
+ </p>
+ {% if film.notes_excerpt %}
+ <p class="notes-preview">{{ film.notes_excerpt }}</p>
+ {% endif %}
+ </div>
+ </article>
+ {% endfor %}
+ {% endif %}
+
+ {% if highlight_films.first_watch %}
+ <article class="highlight-card">
+ <a class="poster-frame" href="/films/{{ highlight_films.first_watch.id }}">
+ {% if highlight_films.first_watch.poster_url %}
+ <img src="{{ highlight_films.first_watch.poster_url }}" alt="{{ highlight_films.first_watch.title }} poster">
+ {% else %}
+ <span>{{ highlight_films.first_watch.title[:1] }}</span>
+ {% endif %}
+ </a>
+ <div>
+ <p class="summary-label">First watch</p>
+ <h2><a href="/films/{{ highlight_films.first_watch.id }}">{{ highlight_films.first_watch.title }}</a></h2>
+ <p class="muted">{{ highlight_films.first_watch.date_watched }}</p>
+ </div>
+ </article>
+ {% endif %}
+
+ {% if highlight_films.last_watch %}
+ <article class="highlight-card">
+ <a class="poster-frame" href="/films/{{ highlight_films.last_watch.id }}">
+ {% if highlight_films.last_watch.poster_url %}
+ <img src="{{ highlight_films.last_watch.poster_url }}" alt="{{ highlight_films.last_watch.title }} poster">
+ {% else %}
+ <span>{{ highlight_films.last_watch.title[:1] }}</span>
+ {% endif %}
+ </a>
+ <div>
+ <p class="summary-label">Last watch</p>
+ <h2><a href="/films/{{ highlight_films.last_watch.id }}">{{ highlight_films.last_watch.title }}</a></h2>
+ <p class="muted">{{ highlight_films.last_watch.date_watched }}</p>
+ </div>
+ </article>
+ {% endif %}
+
+ {% if highlight_films.most_rewatched %}
+ <article class="highlight-card">
+ <a class="poster-frame" href="/films/{{ highlight_films.most_rewatched.id }}">
+ {% if highlight_films.most_rewatched.poster_url %}
+ <img src="{{ highlight_films.most_rewatched.poster_url }}" alt="{{ highlight_films.most_rewatched.title }} poster">
+ {% else %}
+ <span>{{ highlight_films.most_rewatched.title[:1] }}</span>
+ {% endif %}
+ </a>
+ <div>
+ <p class="summary-label">Most rewatched</p>
+ <h2><a href="/films/{{ highlight_films.most_rewatched.id }}">{{ highlight_films.most_rewatched.title }}</a></h2>
+ <p class="muted">{{ highlight_films.most_rewatched.rewatch_count }} rewatches</p>
+ </div>
+ </article>
+ {% endif %}
+ </div>
+ </section>
+ {% endif %}
+{% endblock %}