From a3df69ff5218cee132a6def9d860cd0276cc0cd4 Mon Sep 17 00:00:00 2001
From: Tyler Hoang
Date: Wed, 6 May 2026 14:34:01 -0700
Subject: Add year review and inline diary ratings
---
routers/films.py | 1 -
routers/stats.py | 207 ++++++++++++++++++++++++++++++++++++++++++
static/app.js | 78 ++++++++++++++++
static/styles.css | 221 +++++++++++++++++++++++++++++++++++++++++++++
templates/_film_card.html | 14 ++-
templates/detail.html | 80 ++++++++++++----
templates/stats.html | 9 +-
templates/year_review.html | 175 +++++++++++++++++++++++++++++++++++
8 files changed, 762 insertions(+), 23 deletions(-)
create mode 100644 templates/year_review.html
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 %}
- {% if film.stars %}
+ {% if film.shelf == 'diary' %}
+
+ {% for value in range(1, 4) %}
+
+ {% endfor %}
+
+ {% elif film.stars %}
{% for _ in range(film.stars) %}✦{% endfor %}
{% endif %}
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 @@
{{ film.title[:1] }}
{% endif %}
+
@@ -31,29 +45,57 @@
{% endif %}
-
- {% if film.stars %}{% for _ in range(film.stars) %}✦{% endfor %}{% endif %}
- {% if film.date_watched %}{{ film.date_watched }}{% endif %}
- {% if film.runtime %}{{ film.runtime }} min{% endif %}
- {% if film.rewatch %}Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}{% endif %}
- {% if film.country %}{{ film.country }}{% endif %}
- {% if film.language %}{{ film.language }}{% endif %}
-
+
+
+ Watch log
+
+ {% if film.date_watched %}{{ film.date_watched }}{% endif %}
+ {% if film.runtime %}{{ film.runtime }} min{% endif %}
+ {% if film.rewatch %}Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}{% endif %}
+ {% if film.watched_with %}With {{ film.watched_with }}{% endif %}
+ {% if film.context %}{{ film.context }}{% endif %}
+ {% if film.how_found %}{{ film.how_found }}{% endif %}
+
+
- {% if film.context or film.how_found or film.watched_with %}
-
- {% if film.context %}{{ film.context }}{% endif %}
- {% if film.how_found %}{{ film.how_found }}{% endif %}
- {% if film.watched_with %}With {{ film.watched_with }}{% endif %}
-
- {% endif %}
+
+ Production
+
+ {% if film.country %}{{ film.country }}{% endif %}
+ {% if film.language %}{{ film.language }}{% endif %}
+ {% if film.year %}{{ film.year }}{% endif %}
+ {% if film.tmdb_id %}TMDB {{ film.tmdb_id }}{% endif %}
+
+
+
- {% if film.notes %}
-
- {{ film.notes }}
-
+ {% if tmdb_context %}
+
+ Summary
+ {% if tmdb_context.tagline %}
+ {{ tmdb_context.tagline }}
+ {% endif %}
+ {% if tmdb_context.overview %}
+ {{ tmdb_context.overview }}
+ {% endif %}
+ {% if tmdb_context.cast %}
+
+
Cast
+
{{ tmdb_context.cast|join(", ") }}
+
+ {% endif %}
+
{% endif %}
+
+ Notes
+ {% if film.notes %}
+ {{ film.notes }}
+ {% else %}
+ No notes saved.
+ {% endif %}
+
+
Edit
{% 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 %}
- Stats
- Watching patterns
+
+
+
Stats
+
Watching patterns
+
+
Year in review
+
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 %}
+
+
+
Year in Review
+
{{ selected_year }}
+
A snapshot of the films you logged that year.
+
+
+
+
+
+ {% if total_watched == 0 %}
+
+ No entries
+ No diary films for this year
+ Pick another year or add more diary entries.
+
+ {% else %}
+
+
+ Films watched
+ {{ total_watched }}
+
+
+ Average stars
+ {{ "%.1f"|format(average_stars) }}
+
+
+ Rewatch rate
+ {{ (rewatch_rate.rate * 100)|round(0) }}%
+
+
+ Top director
+ {% if top_director %}{{ top_director.director }}{% else %}—{% endif %}
+
+
+ Top month
+ {% if top_month %}{{ top_month.month }}{% else %}—{% endif %}
+
+
+
+
+
+ Monthly activity
+
+ {% set max_month = films_per_month | map(attribute='count') | max if films_per_month else 1 %}
+ {% for item in films_per_month %}
+
+
{{ item.month[:3] }}
+
+
{{ item.count }}
+
+ {% endfor %}
+
+
+
+
+ Stars
+
+ {% set max_stars = star_distribution | map(attribute='count') | max if star_distribution else 1 %}
+ {% for item in star_distribution %}
+
+
{{ item.stars }} star
+
+
{{ item.count }}
+
+ {% endfor %}
+
+
+
+
+ Companions
+
+ {% for item in watched_with_breakdown %}
+ - {{ item.watched_with }}{{ item.count }}
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+{% endblock %}
--
cgit v1.3-2-g0d8e