summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--routers/films.py31
-rw-r--r--routers/stats.py20
-rw-r--r--static/styles.css282
-rw-r--r--templates/detail.html82
-rw-r--r--templates/index.html10
-rw-r--r--templates/stats.html223
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();