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 %} +
+
+ Shelf + {{ film.shelf|title }} +
+
+ Watched + {% if film.date_watched %}{{ film.date_watched }}{% else %}Not set{% endif %} +
+
+ Stars + {% if film.stars %}{% for _ in range(film.stars) %}✦{% endfor %}{% else %}Unstarred{% 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 %} +
  1. {{ item.watched_with }}{{ item.count }}
  2. + {% endfor %} +
+
+
+ +
+

Highlights

+
+ {% if highlight_films.highest_rated %} + {% for film in highlight_films.highest_rated %} + + {% endfor %} + {% endif %} + + {% if highlight_films.first_watch %} + + {% endif %} + + {% if highlight_films.last_watch %} + + {% endif %} + + {% if highlight_films.most_rewatched %} + + {% endif %} +
+
+ {% endif %} +{% endblock %} -- cgit v1.3-2-g0d8e