diff options
| -rw-r--r-- | routers/films.py | 31 | ||||
| -rw-r--r-- | routers/stats.py | 20 | ||||
| -rw-r--r-- | static/styles.css | 282 | ||||
| -rw-r--r-- | templates/detail.html | 82 | ||||
| -rw-r--r-- | templates/index.html | 10 | ||||
| -rw-r--r-- | templates/stats.html | 223 |
6 files changed, 493 insertions, 155 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 @@ <section class="detail-body"> <section class="detail-hero"> <div class="detail-hero-copy"> - <p class="eyebrow">{{ film.shelf|title }} Entry</p> + <p class="eyebrow"><a class="detail-backlink" href="{{ shelf_meta.path }}">Back to {{ film.shelf|title }}</a></p> <h1>{{ film.title }}</h1> {% if film.original_title %} <p class="original-title">{{ film.original_title }}</p> @@ -66,31 +66,51 @@ </div> {% endif %} </div> + <div class="ledger-strip detail-ledger"> + <span>{{ film.shelf|title }}</span> + {% 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.stars %}<span class="rating">{% for _ in range(film.stars) %}✦{% endfor %}</span>{% endif %} + </div> </div> <div class="detail-actions"> - <a class="secondary-button" href="/films/{{ film.id }}/edit">Edit</a> - {% if film.shelf == 'queue' %} - <a class="button-link" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a> - <form method="post" action="/films/{{ film.id }}/shelf/abandoned"> - <button class="secondary-button" type="submit">Abandon</button> - </form> - {% elif film.shelf == 'diary' %} - <form method="post" action="/films/{{ film.id }}/shelf/queue"> - <button class="secondary-button" type="submit">Move to queue</button> - </form> - <form method="post" action="/films/{{ film.id }}/shelf/abandoned"> - <button class="secondary-button" type="submit">Mark abandoned</button> - </form> - {% else %} - <a class="button-link" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a> - <form method="post" action="/films/{{ film.id }}/shelf/queue"> - <button class="secondary-button" type="submit">Move to queue</button> + <div class="detail-actions-primary"> + {% if film.shelf == 'queue' %} + <a class="button-link" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a> + {% elif film.shelf == 'abandoned' %} + <a class="button-link" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a> + {% else %} + <a class="button-link" href="/films/{{ film.id }}/edit">Edit entry</a> + {% endif %} + </div> + <div class="detail-actions-secondary"> + {% if film.shelf != 'diary' %} + <a class="secondary-button" href="/films/{{ film.id }}/edit">Edit</a> + {% endif %} + {% if film.shelf == 'queue' %} + <form method="post" action="/films/{{ film.id }}/shelf/abandoned"> + <button class="secondary-button" type="submit">Abandon</button> + </form> + {% elif film.shelf == 'diary' %} + <form method="post" action="/films/{{ film.id }}/shelf/queue"> + <button class="secondary-button" type="submit">Move to queue</button> + </form> + <form method="post" action="/films/{{ film.id }}/shelf/abandoned"> + <button class="secondary-button" type="submit">Mark abandoned</button> + </form> + {% else %} + <form method="post" action="/films/{{ film.id }}/shelf/queue"> + <button class="secondary-button" type="submit">Move to queue</button> + </form> + {% endif %} + </div> + <div class="detail-actions-danger"> + <form method="post" action="/films/{{ film.id }}/delete"> + <button class="danger-button" type="submit">Delete</button> </form> - {% endif %} - <form method="post" action="/films/{{ film.id }}/delete"> - <button class="danger-button" type="submit">Delete</button> - </form> + </div> </div> </section> @@ -191,6 +211,15 @@ </aside> <div class="detail-main"> + <section class="detail-panel detail-panel-feature"> + <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> + {% if tmdb_context %} <section class="detail-panel detail-panel-feature"> <p class="eyebrow">Summary</p> @@ -209,15 +238,6 @@ </section> {% endif %} - <section class="detail-panel detail-panel-feature"> - <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> - {% if rewatch_history|length > 1 %} <section class="detail-panel"> <p class="eyebrow">Rewatches</p> 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 @@ <p class="shelf-hero-text">{{ shelf_meta.empty_text }}</p> </div> <div class="shelf-hero-meta"> - <div class="shelf-stat"> - <span class="summary-label">Entries</span> - <strong>{{ total_films or 0 }}</strong> + <div class="shelf-hero-stats"> + {% for stat in shelf_snapshot %} + <div class="shelf-stat"> + <span class="summary-label">{{ stat.label }}</span> + <strong>{{ stat.value }}</strong> + </div> + {% endfor %} </div> {% if active_shelf == 'queue' %} <a class="button-link" href="/queue/random">Surprise me</a> 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 %} - <section class="page-heading"> - <div class="page-heading-row"> - <div> - <p class="eyebrow">Stats</p> - <h1>Watching patterns</h1> - </div> + <section class="stats-hero"> + <div class="stats-hero-copy"> + <p class="eyebrow">Stats</p> + <h1>Watching patterns</h1> + <p class="stats-hero-text">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.</p> + </div> + <div class="stats-hero-actions"> <a class="button-link" href="/stats/year-in-review">Year in review</a> </div> </section> - <section class="stats-layout"> - <section class="stats-panel stats-panel-wide"> - <div class="stats-overview-row"> - <div class="stats-metric"> - <span class="eyebrow">Films watched</span> - <strong id="stats-total-films">—</strong> - </div> - <div class="stats-metric"> - <span class="eyebrow">Total runtime</span> - <strong id="stats-total-runtime">—</strong> - </div> - </div> - </section> + <section class="stats-summary"> + <article class="stats-summary-card"> + <span class="summary-label">Films watched</span> + <strong id="stats-total-films">—</strong> + </article> + <article class="stats-summary-card"> + <span class="summary-label">Total runtime</span> + <strong id="stats-total-runtime">—</strong> + </article> + <article class="stats-summary-card"> + <span class="summary-label">Average stars</span> + <strong><span id="stats-average-stars">—</span><span class="stats-average-mark" aria-hidden="true">✦</span></strong> + </article> + <article class="stats-summary-card"> + <span class="summary-label">Rewatch rate</span> + <strong id="stats-rewatch-rate">—</strong> + </article> + </section> + + <section class="stats-context-strip"> + <article class="stats-context-card"> + <span class="summary-label">Top director</span> + <strong id="stats-top-director">—</strong> + <p id="stats-top-director-meta" class="stats-context-meta">No repeat viewings yet.</p> + </article> + <article class="stats-context-card"> + <span class="summary-label">Most active month</span> + <strong id="stats-top-month">—</strong> + <p id="stats-top-month-meta" class="stats-context-meta">No monthly cluster yet.</p> + </article> + <article class="stats-context-card"> + <span class="summary-label">Countries logged</span> + <strong id="stats-country-count">—</strong> + <p class="stats-context-meta">Based on films with country metadata.</p> + </article> + </section> - <section class="stats-panel stats-panel-wide"> + <section class="stats-research"> + <section class="stats-panel stats-panel-feature"> <div class="stats-panel-header"> <div> - <p class="eyebrow">World Map</p> - <h2>Films per country</h2> + <p class="eyebrow">World map</p> + <h2>Films by country</h2> </div> + <p class="stats-panel-note">The map functions as a reading aid. Hover to inspect where your diary clusters geographically.</p> </div> - <div id="world-map" class="stats-map" aria-label="World map of watched films"></div> - <div id="map-readout" class="map-readout" aria-live="polite"> - <strong>Hover a country</strong> - <span>Film count will appear here.</span> + <div class="stats-map-shell"> + <div id="world-map" class="stats-map" aria-label="World map of watched films"></div> + <div id="map-readout" class="map-readout" aria-live="polite"> + <strong>Hover a country</strong> + <span>Film count will appear here.</span> + </div> </div> <div id="map-tooltip" class="stats-tooltip" hidden></div> </section> - <section class="stats-panel stats-panel-wide"> + <section class="stats-panel stats-panel-feature"> <div class="stats-panel-header"> <div> - <p class="eyebrow">Heatmap</p> + <p class="eyebrow">Calendar</p> <h2>Past 365 days</h2> </div> + <p class="stats-panel-note">A density view of your diary cadence over the trailing year.</p> </div> <div id="watch-heatmap" class="heatmap-shell" aria-label="Daily watch heatmap"></div> </section> - <section class="stats-panel"> - <div class="stats-panel-header"> - <div> - <p class="eyebrow">Directors</p> - <h2>Most watched</h2> + <section class="stats-column"> + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Directors</p> + <h2>Most watched</h2> + </div> </div> - </div> - <ol id="top-directors" class="stats-list"></ol> - </section> + <ol id="top-directors" class="stats-list"></ol> + </section> - <section class="stats-panel"> - <div class="stats-panel-header"> - <div> - <p class="eyebrow">Stars</p> - <h2>Distribution</h2> + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Genres</p> + <h2>Most watched</h2> + </div> </div> - </div> - <div id="star-distribution" class="stats-bars"></div> - </section> + <ol id="top-genres" class="stats-list"></ol> + </section> - <section class="stats-panel"> - <div class="stats-panel-header"> - <div> - <p class="eyebrow">Films</p> - <h2>Rewatches</h2> + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Companions</p> + <h2>Watched with</h2> + </div> </div> - </div> - <div id="rewatch-patterns-list" class="stats-list"></div> + <ol id="watched-with" class="stats-list"></ol> + </section> </section> - <section class="stats-panel"> - <div class="stats-panel-header"> - <div> - <p class="eyebrow">Companions</p> - <h2>Watched with</h2> + <section class="stats-column"> + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Stars</p> + <h2>Distribution</h2> + </div> </div> - </div> - <ol id="watched-with" class="stats-list"></ol> - </section> + <div id="star-distribution" class="stats-bars"></div> + </section> - <section class="stats-panel"> - <div class="stats-panel-header"> - <div> - <p class="eyebrow">Genres</p> - <h2>Most watched</h2> + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Films</p> + <h2>Rewatches</h2> + </div> </div> - </div> - <ol id="top-genres" class="stats-list"></ol> - </section> + <div id="rewatch-patterns-list" class="stats-list"></div> + </section> - <section class="stats-panel"> - <div class="stats-panel-header"> - <div> - <p class="eyebrow">Decades</p> - <h2>By era</h2> + <section class="stats-panel"> + <div class="stats-panel-header"> + <div> + <p class="eyebrow">Decades</p> + <h2>By era</h2> + </div> </div> - </div> - <ol id="film-decades" class="stats-list"></ol> + <ol id="film-decades" class="stats-list"></ol> + </section> </section> </section> {% 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) => ` <li><span>${item.director}</span><strong>${item.count}</strong></li> @@ -200,7 +259,7 @@ const gap = item.days_between != null ? `<span style="color:var(--subtle);font-size:13px">${item.days_between}d apart</span>` : ""; - return `<div class="stats-list-row"> + return `<div class="stats-list-row stats-list-row-rewatch"> <span>${item.title}</span> <span style="display:flex;gap:12px;align-items:center">${gap}${drift}<strong>${item.watches}×</strong></span> </div>`; @@ -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(); |
