From 20c1d02b40bcb9abb5882d0503e596c82e9819bb Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Fri, 15 May 2026 01:50:15 -0700 Subject: Refine Lumi stats and detail UX --- routers/films.py | 31 ++++++ routers/stats.py | 20 ++++ static/styles.css | 282 ++++++++++++++++++++++++++++++++++++++++++-------- templates/detail.html | 82 +++++++++------ templates/index.html | 10 +- templates/stats.html | 231 +++++++++++++++++++++++++++-------------- 6 files changed, 497 insertions(+), 159 deletions(-) diff --git a/routers/films.py b/routers/films.py index 56f50c5..21feab8 100644 --- a/routers/films.py +++ b/routers/films.py @@ -220,6 +220,34 @@ def _group_films_by_month(films: list[Film]) -> list[dict]: return grouped +def _shelf_snapshot(films: list[Film], shelf: str) -> list[dict[str, str | int]]: + if shelf == "diary": + rated = sum(1 for film in films if film.stars) + latest_watch = next((film.date_watched for film in films if film.date_watched), None) + return [ + {"label": "Entries", "value": len(films)}, + {"label": "Rated", "value": rated}, + {"label": "Latest", "value": latest_watch.isoformat() if latest_watch else "Not set"}, + ] + + if shelf == "queue": + with_posters = sum(1 for film in films if film.poster_url) + with_notes = sum(1 for film in films if film.notes) + return [ + {"label": "Queued", "value": len(films)}, + {"label": "With posters", "value": with_posters}, + {"label": "With notes", "value": with_notes}, + ] + + rated = sum(1 for film in films if film.stars) + with_notes = sum(1 for film in films if film.notes) + return [ + {"label": "Entries", "value": len(films)}, + {"label": "Rated", "value": rated}, + {"label": "With notes", "value": with_notes}, + ] + + def _render_shelf( shelf: str, request: Request, @@ -228,6 +256,7 @@ def _render_shelf( ): query = _get_shelf_query(db, shelf) total_films = query.count() + shelf_snapshot = _shelf_snapshot(query.all(), shelf) films = query.limit(20).all() has_more = total_films > 20 @@ -245,6 +274,7 @@ def _render_shelf( "grouped_films": grouped_films, "active_shelf": shelf, "shelf_meta": SHELF_META[shelf], + "shelf_snapshot": shelf_snapshot, "total_films": total_films, "has_more": has_more, **notices, @@ -483,6 +513,7 @@ async def film_detail(film_id: int, request: Request, db: Session = Depends(get_ "request": request, "film": film, "active_shelf": film.shelf, + "shelf_meta": SHELF_META.get(film.shelf, SHELF_META["diary"]), "tmdb_context": tmdb_context, "rewatch_history": rewatch_history, "ratings": ratings, diff --git a/routers/stats.py b/routers/stats.py index d600e5a..ad07e0c 100644 --- a/routers/stats.py +++ b/routers/stats.py @@ -65,7 +65,9 @@ def _build_stats_payload(films: list[Film]) -> dict: watched_with["solo"] += 1 total_watched = len(films) + rewatches = sum(1 for film in films if film.rewatch or film.rewatch_count > 0) total_runtime_minutes = sum(film.runtime for film in films if film.runtime) + average_stars = round(sum(film.stars for film in films) / total_watched, 1) if total_watched else 0 title_groups = defaultdict(list) for film in films: @@ -90,6 +92,16 @@ def _build_stats_payload(films: list[Film]) -> dict: rewatch_details.sort(key=lambda x: (-x["watches"], x["title"])) + 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 months: + top_month_key, top_month_count = sorted(months.items(), key=lambda item: (-item[1], item[0]))[0] + top_month = {"month": top_month_key, "count": top_month_count} + today = date.today() start_day = today - timedelta(days=364) trailing_days = [] @@ -105,6 +117,7 @@ def _build_stats_payload(films: list[Film]) -> dict: }, "total_watched": total_watched, "total_runtime_minutes": total_runtime_minutes, + "average_stars": average_stars, "films_per_country": [ {"country": country, "count": count} for country, count in sorted(countries.items(), key=lambda item: (-item[1], item[0])) @@ -143,6 +156,13 @@ def _build_stats_payload(films: list[Film]) -> dict: {"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": rewatches, + "total_watched": total_watched, + "rate": round(rewatches / total_watched, 4) if total_watched else 0, + }, + "top_director": top_director, + "top_month": top_month, } diff --git a/static/styles.css b/static/styles.css index 95433a5..ebed3c3 100644 --- a/static/styles.css +++ b/static/styles.css @@ -640,6 +640,7 @@ h2 { .detail-aside-meta { display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; border: 1px solid var(--line); border-radius: 6px; @@ -737,7 +738,25 @@ h2 { align-items: center; gap: 16px; flex-wrap: wrap; - margin-bottom: 20px; + margin-bottom: 8px; +} + +.detail-backlink { + color: var(--subtle); + border-bottom: 0; +} + +.detail-backlink:hover { + color: var(--text); +} + +.detail-ledger { + margin-top: 0; +} + +.detail-ledger .rating { + display: inline-flex; + align-items: center; } .ratings-inline { @@ -783,7 +802,7 @@ h2 { } .notes-body { - margin-top: 28px; + margin-top: 18px; max-width: 68ch; color: var(--muted); white-space: pre-wrap; @@ -792,13 +811,29 @@ h2 { } .detail-actions { - margin-top: 34px; + display: grid; + gap: 12px; + justify-items: end; } .detail-actions form { margin: 0; } +.detail-actions-primary, +.detail-actions-secondary, +.detail-actions-danger { + display: flex; + flex-wrap: wrap; + justify-content: end; + gap: 10px; +} + +.detail-actions-danger { + padding-top: 12px; + border-top: 1px solid var(--line); +} + .form-shell { width: min(900px, 100%); padding-bottom: 64px; @@ -974,21 +1009,134 @@ textarea:focus { justify-content: flex-end; } -.stats-layout { +.stats-hero { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr) auto; + gap: 24px; + align-items: end; + margin-bottom: 18px; + border: 1px solid var(--line); + border-radius: 10px; + background: + linear-gradient(180deg, rgba(17, 21, 28, 0.96), rgba(11, 14, 19, 0.84)), + radial-gradient(circle at top left, rgba(194, 170, 122, 0.08), transparent 22rem); + padding: 32px; + box-shadow: var(--shadow); +} + +.stats-hero-copy { + max-width: 52rem; +} + +.stats-hero-text { + margin: 14px 0 0; + color: var(--muted); + line-height: 1.7; + max-width: 60ch; +} + +.stats-hero-actions { + display: flex; + align-items: end; + justify-content: end; +} + +.stats-summary, +.stats-context-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} + +.stats-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.stats-context-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 28px; +} + +.stats-summary-card, +.stats-context-card { + border: 1px solid var(--line); + border-radius: 6px; + background: linear-gradient(180deg, rgba(17, 21, 28, 0.98), rgba(17, 21, 28, 0.82)); + padding: 18px; + box-shadow: var(--shadow-inset); +} + +.stats-summary-card strong, +.stats-context-card strong { + display: block; + margin-top: 8px; + font-family: var(--font-display); + font-size: clamp(1.8rem, 4vw, 2.7rem); + font-weight: 500; + color: var(--accent); + line-height: 1.05; +} + +.stats-average-mark { + display: inline-block; + margin-left: 0.18em; + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 0.76em; + vertical-align: 0.16em; +} + +.stats-context-meta { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.55; +} + +.stats-research { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); gap: 18px; padding-bottom: 64px; } -.stats-panel-wide { +.stats-panel-feature { grid-column: 1 / -1; } +.stats-column { + display: grid; + gap: 18px; + align-content: start; +} + +.stats-panel { + display: grid; + gap: 16px; +} + +.stats-panel-feature { + align-content: start; +} + .stats-panel h2 { margin: 2px 0 0; } +.stats-panel-note { + max-width: 34ch; + margin: 0; + color: var(--muted); + font-size: 0.92rem; + line-height: 1.6; +} + +.stats-map-shell { + display: grid; + gap: 14px; +} + .stats-map { position: relative; min-height: 320px; @@ -1039,37 +1187,41 @@ textarea:focus { .heatmap-shell { overflow: visible; + min-width: 0; + --heatmap-cell: 13px; + --heatmap-gap: 4px; + --heatmap-weekday-width: 34px; } .heatmap-months { display: grid; - grid-template-columns: repeat(53, 13px); - gap: 4px; - margin: 0 0 8px 42px; + grid-template-columns: repeat(53, var(--heatmap-cell)); + gap: var(--heatmap-gap); + margin: 0 0 8px calc(var(--heatmap-weekday-width) + 8px); color: var(--muted); font-size: 0.72rem; } .heatmap-body { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); + display: inline-grid; + grid-template-columns: var(--heatmap-weekday-width) minmax(0, max-content); gap: 8px; align-items: start; } .heatmap-weekdays { display: grid; - grid-template-rows: repeat(7, 13px); - gap: 4px; + grid-template-rows: repeat(7, var(--heatmap-cell)); + gap: var(--heatmap-gap); color: var(--muted); font-size: 0.72rem; } .heatmap-grid { display: grid; - grid-template-columns: repeat(53, 13px); - grid-template-rows: repeat(7, 13px); - gap: 4px; + grid-template-columns: repeat(53, var(--heatmap-cell)); + grid-template-rows: repeat(7, var(--heatmap-cell)); + gap: var(--heatmap-gap); } .heatmap-cell { @@ -1092,6 +1244,15 @@ textarea:focus { grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; + padding-bottom: 10px; + border-bottom: 1px solid var(--line); +} + +.stats-list li:last-child, +.stats-bar-row:last-child, +.stats-list-row:last-child { + padding-bottom: 0; + border-bottom: 0; } .stats-list li strong, @@ -1122,19 +1283,17 @@ textarea:focus { background: linear-gradient(90deg, rgba(194, 170, 122, 0.35), var(--accent)); } -.stats-overview-row { - display: flex; - gap: 40px; - flex-wrap: wrap; -} - -.stats-metric { +.stats-list-row { display: grid; - gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding-bottom: 10px; + border-bottom: 1px solid var(--line); } -.stats-metric span { - color: var(--muted); +.stats-list-row-rewatch { + font-size: 0.94rem; } .review-hero { @@ -1701,13 +1860,20 @@ textarea:focus { .shelf-hero-meta { display: grid; gap: 16px; - justify-items: end; + justify-items: stretch; + min-width: min(100%, 430px); +} + +.shelf-hero-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; } .shelf-stat { display: grid; gap: 6px; - min-width: 140px; + min-width: 0; padding: 14px 16px; border: 1px solid var(--line); border-radius: 8px; @@ -1717,8 +1883,9 @@ textarea:focus { .shelf-stat strong { color: var(--accent); font-family: var(--font-display); - font-size: 2rem; + font-size: clamp(1.25rem, 2vw, 2rem); font-weight: 500; + line-height: 1.1; } .feed-toolbar { @@ -1981,7 +2148,7 @@ textarea:focus { } .detail-subtitle { - margin-bottom: 0; + margin-bottom: 8px; } .detail-columns { @@ -2006,11 +2173,6 @@ textarea:focus { } .detail-actions { - display: flex; - flex-wrap: wrap; - justify-content: end; - align-items: center; - gap: 10px; margin-top: 0; } @@ -2230,6 +2392,17 @@ textarea:focus { justify-content: start; } + .shelf-hero-stats, + .detail-aside-meta { + grid-template-columns: 1fr; + } + + .detail-actions-primary, + .detail-actions-secondary, + .detail-actions-danger { + justify-content: start; + } + .feed-toolbar { top: 10px; margin-bottom: 20px; @@ -2243,8 +2416,7 @@ textarea:focus { .film-card, .detail-layout, .form-grid, - .director-summary, - .stats-layout { + .director-summary { grid-template-columns: 1fr; } @@ -2296,7 +2468,11 @@ textarea:focus { } .review-metrics, - .review-grid { + .review-grid, + .stats-summary, + .stats-context-strip, + .stats-research, + .stats-hero { grid-template-columns: 1fr; } @@ -2306,22 +2482,22 @@ textarea:focus { } .heatmap-months { - grid-template-columns: repeat(53, 8px); - gap: 2px; - margin: 0 0 8px 20px; + grid-template-columns: repeat(53, var(--heatmap-cell)); + gap: var(--heatmap-gap); + margin: 0 0 8px calc(var(--heatmap-weekday-width) + 6px); font-size: 0.64rem; } .heatmap-weekdays { - grid-template-rows: repeat(7, 8px); - gap: 2px; + grid-template-rows: repeat(7, var(--heatmap-cell)); + gap: var(--heatmap-gap); font-size: 0.64rem; } .heatmap-grid { - grid-template-columns: repeat(53, 8px); - grid-template-rows: repeat(7, 8px); - gap: 2px; + grid-template-columns: repeat(53, var(--heatmap-cell)); + grid-template-rows: repeat(7, var(--heatmap-cell)); + gap: var(--heatmap-gap); } .stats-bar-row { @@ -2331,4 +2507,18 @@ textarea:focus { .stats-list li { grid-template-columns: minmax(0, 1fr) auto; } + + .stats-hero, + .stats-panel { + padding: 22px 20px; + } + + .stats-summary-card, + .stats-context-card { + padding: 18px 16px; + } + + .stats-hero-actions { + justify-content: start; + } } diff --git a/templates/detail.html b/templates/detail.html index f51372d..94612d7 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -27,7 +27,7 @@
-

{{ film.shelf|title }} Entry

+

Back to {{ film.shelf|title }}

{{ film.title }}

{% if film.original_title %}

{{ film.original_title }}

@@ -66,31 +66,51 @@
{% endif %} +
+ {{ film.shelf|title }} + {% 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.stars %}{% for _ in range(film.stars) %}✦{% endfor %}{% endif %} +
- Edit - {% if film.shelf == 'queue' %} - Mark watched -
- -
- {% elif film.shelf == 'diary' %} -
- -
-
- -
- {% else %} - Mark watched -
- +
+ {% if film.shelf == 'queue' %} + Mark watched + {% elif film.shelf == 'abandoned' %} + Mark watched + {% else %} + Edit entry + {% endif %} +
+
+ {% if film.shelf != 'diary' %} + Edit + {% endif %} + {% if film.shelf == 'queue' %} + + + + {% elif film.shelf == 'diary' %} +
+ +
+
+ +
+ {% else %} +
+ +
+ {% endif %} +
+
+
+
- {% endif %} -
- -
+
@@ -191,6 +211,15 @@
+
+

Notes

+ {% if film.notes %} +
{{ film.notes }}
+ {% else %} +

No notes saved.

+ {% endif %} +
+ {% if tmdb_context %}

Summary

@@ -209,15 +238,6 @@
{% endif %} -
-

Notes

- {% if film.notes %} -
{{ film.notes }}
- {% else %} -

No notes saved.

- {% endif %} -
- {% if rewatch_history|length > 1 %}

Rewatches

diff --git a/templates/index.html b/templates/index.html index 7e74c07..5820cf4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10,9 +10,13 @@

{{ shelf_meta.empty_text }}

-
- Entries - {{ total_films or 0 }} +
+ {% for stat in shelf_snapshot %} +
+ {{ stat.label }} + {{ stat.value }} +
+ {% endfor %}
{% if active_shelf == 'queue' %} Surprise me diff --git a/templates/stats.html b/templates/stats.html index 6e3a987..efb952c 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -3,113 +3,146 @@ {% block title %}Stats · Lumière{% endblock %} {% block content %} -
-
-
-

Stats

-

Watching patterns

-
+
+
+

Stats

+

Watching patterns

+

A research spread for your diary: the geography of what you watch, the people and genres that recur, and the rhythms that define a year of logging.

+
+
-
-
-
-
- Films watched - -
-
- Total runtime - -
-
-
+
+
+ Films watched + +
+
+ Total runtime + +
+
+ Average stars + +
+
+ Rewatch rate + +
+
-
+
+
+ Top director + +

No repeat viewings yet.

+
+
+ Most active month + +

No monthly cluster yet.

+
+
+ Countries logged + +

Based on films with country metadata.

+
+
+ +
+
-

World Map

-

Films per country

+

World map

+

Films by country

+

The map functions as a reading aid. Hover to inspect where your diary clusters geographically.

-
-
- Hover a country - Film count will appear here. +
+
+
+ Hover a country + Film count will appear here. +
-
+
-

Heatmap

+

Calendar

Past 365 days

+

A density view of your diary cadence over the trailing year.

-
-
-
-

Directors

-

Most watched

+
+
+
+
+

Directors

+

Most watched

+
-
-
    -
    - -
    -
    -
    -

    Stars

    -

    Distribution

    +
      +
      + +
      +
      +
      +

      Genres

      +

      Most watched

      +
      -
      -
      -
      - -
      -
      -
      -

      Films

      -

      Rewatches

      +
        +
        + +
        +
        +
        +

        Companions

        +

        Watched with

        +
        -
        -
        +
          +
          -
          -
          -
          -

          Companions

          -

          Watched with

          +
          +
          +
          +
          +

          Stars

          +

          Distribution

          +
          -
          -
            -
            - -
            -
            -
            -

            Genres

            -

            Most watched

            +
            +
            + +
            +
            +
            +

            Films

            +

            Rewatches

            +
            - -
              -
              - -
              -
              -
              -

              Decades

              -

              By era

              +
              +
              + +
              +
              +
              +

              Decades

              +

              By era

              +
              - -
                +
                  +
                  {% endblock %} @@ -133,6 +166,13 @@ return new Date(`${dateString}T00:00:00`).toLocaleDateString(undefined, { month: "short" }); } + function formatStatsMonth(monthKey) { + if (!monthKey) return "—"; + const [year, month] = monthKey.split("-"); + const date = new Date(Number(year), Number(month) - 1, 1); + return date.toLocaleDateString(undefined, { month: "long", year: "numeric" }); + } + function formatCountryCode(value) { return String(value).padStart(3, "0"); } @@ -156,6 +196,25 @@ const totalRuntime = document.getElementById("stats-total-runtime"); if (totalRuntime) totalRuntime.textContent = formatRuntime(data.total_runtime_minutes); + const averageStars = document.getElementById("stats-average-stars"); + if (averageStars) averageStars.textContent = data.average_stars ? data.average_stars.toFixed(1) : "—"; + + const rewatchRate = document.getElementById("stats-rewatch-rate"); + if (rewatchRate) rewatchRate.textContent = data.rewatch_rate ? `${Math.round(data.rewatch_rate.rate * 100)}%` : "—"; + + const topDirector = document.getElementById("stats-top-director"); + const topDirectorMeta = document.getElementById("stats-top-director-meta"); + if (topDirector) topDirector.textContent = data.top_director ? data.top_director.director : "—"; + if (topDirectorMeta) topDirectorMeta.textContent = data.top_director ? `${data.top_director.count} films logged` : "No repeat viewings yet."; + + const topMonth = document.getElementById("stats-top-month"); + const topMonthMeta = document.getElementById("stats-top-month-meta"); + if (topMonth) topMonth.textContent = data.top_month ? formatStatsMonth(data.top_month.month) : "—"; + if (topMonthMeta) topMonthMeta.textContent = data.top_month ? `${data.top_month.count} diary entries` : "No monthly cluster yet."; + + const countryCount = document.getElementById("stats-country-count"); + if (countryCount) countryCount.textContent = data.films_per_country.length; + const topDirectors = document.getElementById("top-directors"); topDirectors.innerHTML = data.most_watched_directors.slice(0, 8).map((item) => `
                1. ${item.director}${item.count}
                2. @@ -200,7 +259,7 @@ const gap = item.days_between != null ? `${item.days_between}d apart` : ""; - return `
                  + return `
                  ${item.title} ${gap}${drift}${item.watches}×
                  `; @@ -218,6 +277,19 @@ return { ...item, dateObj: date }; }); + const containerWidth = container.clientWidth || 720; + const maxCell = 13; + const minCell = 8; + const weekdayWidth = containerWidth < 640 ? 20 : 34; + const gap = containerWidth < 640 ? 2 : 4; + const availableWidth = Math.max(53 * minCell, containerWidth - weekdayWidth - 8); + const computedCell = Math.floor((availableWidth - (52 * gap)) / 53); + const cellSize = Math.max(minCell, Math.min(maxCell, computedCell)); + + container.style.setProperty("--heatmap-cell", `${cellSize}px`); + container.style.setProperty("--heatmap-gap", `${gap}px`); + container.style.setProperty("--heatmap-weekday-width", `${weekdayWidth}px`); + const firstWeekStart = new Date(parsedDays[0].dateObj); const mondayOffset = (firstWeekStart.getDay() + 6) % 7; firstWeekStart.setDate(firstWeekStart.getDate() - mondayOffset); @@ -343,6 +415,7 @@ renderLists(data); renderHeatmap(data.films_per_day_365); await renderMap(data); + window.addEventListener("resize", () => renderHeatmap(data.films_per_day_365)); } bootStats(); -- cgit v1.3-2-g0d8e