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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 1 deletion(-) (limited to 'routers') 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) -- cgit v1.3-2-g0d8e