summaryrefslogtreecommitdiff
path: root/templates/stats.html
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 12:21:26 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 12:21:26 -0700
commite708bec6cd76c2686de4158dde4d04f72a3c300d (patch)
tree04b0bc4738e090dd7834d47478c7e652da010f92 /templates/stats.html
init: lumiere film diary
Diffstat (limited to 'templates/stats.html')
-rw-r--r--templates/stats.html268
1 files changed, 268 insertions, 0 deletions
diff --git a/templates/stats.html b/templates/stats.html
new file mode 100644
index 0000000..7b54634
--- /dev/null
+++ b/templates/stats.html
@@ -0,0 +1,268 @@
+{% extends "base.html" %}
+
+{% block title %}Stats · Lumière{% endblock %}
+
+{% block content %}
+ <section class="page-heading">
+ <p class="eyebrow">Stats</p>
+ <h1>Watching patterns</h1>
+ </section>
+
+ <section class="stats-layout">
+ <section class="stats-panel stats-panel-wide">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">World Map</p>
+ <h2>Films per country</h2>
+ </div>
+ </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>
+ <div id="map-tooltip" class="stats-tooltip" hidden></div>
+ </section>
+
+ <section class="stats-panel stats-panel-wide">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Heatmap</p>
+ <h2>Past 365 days</h2>
+ </div>
+ </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>
+ </div>
+ </div>
+ <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>
+ </div>
+ </div>
+ <div id="star-distribution" class="stats-bars"></div>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Rewatch</p>
+ <h2>Rate</h2>
+ </div>
+ </div>
+ <div id="rewatch-rate" class="stats-metric"></div>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Companions</p>
+ <h2>Watched with</h2>
+ </div>
+ </div>
+ <ol id="watched-with" class="stats-list"></ol>
+ </section>
+ </section>
+{% endblock %}
+
+{% block scripts %}
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js"></script>
+ <script>
+ const PANEL_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--panel").trim();
+ const PANEL_SOFT_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--panel-soft").trim();
+ const ACCENT_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
+
+ function heatmapColor(count) {
+ if (count <= 0) return PANEL_SOFT_COLOR;
+ if (count === 1) return "rgba(240, 184, 77, 0.35)";
+ if (count === 2) return "rgba(240, 184, 77, 0.65)";
+ return ACCENT_COLOR;
+ }
+
+ function formatMonthLabel(dateString) {
+ return new Date(`${dateString}T00:00:00`).toLocaleDateString(undefined, { month: "short" });
+ }
+
+ function formatCountryCode(value) {
+ return String(value).padStart(3, "0");
+ }
+
+ function renderLists(data) {
+ 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>
+ `).join("");
+
+ const watchedWith = document.getElementById("watched-with");
+ watchedWith.innerHTML = data.watched_with_breakdown.slice(0, 8).map((item) => `
+ <li><span>${item.watched_with}</span><strong>${item.count}</strong></li>
+ `).join("");
+
+ const starDistribution = document.getElementById("star-distribution");
+ const maxStars = Math.max(1, ...data.star_distribution.map((item) => item.count));
+ starDistribution.innerHTML = data.star_distribution.map((item) => `
+ <div class="stats-bar-row">
+ <span>${item.stars} star</span>
+ <div class="stats-bar-track"><div class="stats-bar-fill" style="width:${(item.count / maxStars) * 100}%"></div></div>
+ <strong>${item.count}</strong>
+ </div>
+ `).join("");
+
+ const rewatchRate = document.getElementById("rewatch-rate");
+ const rate = data.rewatch_rate.total_watched ? Math.round(data.rewatch_rate.rate * 100) : 0;
+ rewatchRate.innerHTML = `
+ <strong>${rate}%</strong>
+ <span>${data.rewatch_rate.rewatched} of ${data.rewatch_rate.total_watched} watched films were rewatches.</span>
+ `;
+ }
+
+ function renderHeatmap(days) {
+ const container = document.getElementById("watch-heatmap");
+ const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+ const parsedDays = days.map((item) => {
+ const date = new Date(`${item.date}T00:00:00`);
+ return { ...item, dateObj: date };
+ });
+
+ const firstWeekStart = new Date(parsedDays[0].dateObj);
+ const mondayOffset = (firstWeekStart.getDay() + 6) % 7;
+ firstWeekStart.setDate(firstWeekStart.getDate() - mondayOffset);
+
+ const monthLabels = [];
+ const seenMonths = new Set();
+ const cells = parsedDays.map((item) => {
+ const diffDays = Math.floor((item.dateObj - firstWeekStart) / 86400000);
+ const week = Math.floor(diffDays / 7);
+ const weekday = (item.dateObj.getDay() + 6) % 7;
+ const monthKey = item.date.slice(0, 7);
+ if (!seenMonths.has(monthKey)) {
+ seenMonths.add(monthKey);
+ monthLabels.push({ week, label: formatMonthLabel(item.date) });
+ }
+ return `
+ <button
+ class="heatmap-cell"
+ style="grid-column:${week + 1};grid-row:${weekday + 1};background:${heatmapColor(item.count)}"
+ title="${item.date}: ${item.count} film${item.count === 1 ? "" : "s"}"
+ aria-label="${item.date}: ${item.count} film${item.count === 1 ? "" : "s"}"
+ type="button"
+ ></button>
+ `;
+ }).join("");
+
+ container.innerHTML = `
+ <div class="heatmap-months">
+ ${monthLabels.map((item) => `<span style="grid-column:${item.week + 1}">${item.label}</span>`).join("")}
+ </div>
+ <div class="heatmap-body">
+ <div class="heatmap-weekdays">
+ ${weekdays.map((label) => `<span>${label}</span>`).join("")}
+ </div>
+ <div class="heatmap-grid">${cells}</div>
+ </div>
+ `;
+ }
+
+ async function renderMap(data) {
+ const container = document.getElementById("world-map");
+ const readout = document.getElementById("map-readout");
+ const tooltip = document.getElementById("map-tooltip");
+ const [worldResponse, namesResponse] = await Promise.all([
+ fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"),
+ fetch("https://cdn.jsdelivr.net/gh/lukes/ISO-3166-Countries-with-Regional-Codes@master/all/all.json"),
+ ]);
+ const [world, countryRows] = await Promise.all([worldResponse.json(), namesResponse.json()]);
+ const counts = new Map(data.films_per_country_codes.map((item) => [String(item.code), item.count]));
+ const labels = data.country_labels_by_code || {};
+ const names = countryRows.reduce((map, row) => {
+ map.set(formatCountryCode(row["country-code"]), row.name);
+ return map;
+ }, new Map());
+ const maxCount = Math.max(1, ...data.films_per_country_codes.map((item) => item.count));
+
+ const width = container.clientWidth || 960;
+ const height = Math.min(520, Math.max(320, Math.round(width * 0.55)));
+ const projection = d3.geoNaturalEarth1().fitSize([width, height], topojson.feature(world, world.objects.countries));
+ const path = d3.geoPath(projection);
+ const color = d3.scaleLinear().domain([0, maxCount]).range([PANEL_COLOR, ACCENT_COLOR]);
+
+ const svg = d3.create("svg")
+ .attr("viewBox", `0 0 ${width} ${height}`)
+ .attr("role", "img")
+ .attr("aria-label", "World map shaded by films watched");
+ let activeCountryPath = null;
+
+ function updateReadout(name, count) {
+ readout.innerHTML = `<strong>${name}</strong><span>${count} film${count === 1 ? "" : "s"}</span>`;
+ }
+
+ function resetMapHover() {
+ if (activeCountryPath) {
+ activeCountryPath.attr("stroke", "rgba(244, 239, 230, 0.18)").attr("stroke-width", 0.7);
+ activeCountryPath = null;
+ }
+ readout.innerHTML = `<strong>Hover a country</strong><span>Film count will appear here.</span>`;
+ tooltip.hidden = true;
+ }
+
+ svg.append("g")
+ .selectAll("path")
+ .data(topojson.feature(world, world.objects.countries).features)
+ .join("path")
+ .attr("d", path)
+ .attr("fill", (feature) => color(counts.get(String(feature.id)) || 0))
+ .attr("stroke", "rgba(244, 239, 230, 0.18)")
+ .attr("stroke-width", 0.7)
+ .style("cursor", "pointer")
+ .on("pointerenter", function(event, feature) {
+ const count = counts.get(String(feature.id)) || 0;
+ const featureCode = formatCountryCode(feature.id);
+ const name = names.get(featureCode) || labels[String(feature.id)] || `Country ${feature.id}`;
+ if (activeCountryPath) {
+ activeCountryPath.attr("stroke", "rgba(244, 239, 230, 0.18)").attr("stroke-width", 0.7);
+ }
+ activeCountryPath = d3.select(this);
+ activeCountryPath.attr("stroke", ACCENT_COLOR).attr("stroke-width", 1.4);
+ updateReadout(name, count);
+ tooltip.hidden = false;
+ tooltip.innerHTML = `<strong>${name}</strong><span>${count} film${count === 1 ? "" : "s"}</span>`;
+ tooltip.style.left = `${event.pageX + 12}px`;
+ tooltip.style.top = `${event.pageY + 12}px`;
+ })
+ .on("pointermove", function(event) {
+ tooltip.style.left = `${event.pageX + 12}px`;
+ tooltip.style.top = `${event.pageY + 12}px`;
+ })
+ .on("pointerleave", function() {
+ resetMapHover();
+ });
+
+ container.innerHTML = "";
+ container.appendChild(svg.node());
+ svg.on("pointerleave", resetMapHover);
+ container.addEventListener("mouseleave", resetMapHover);
+ }
+
+ async function bootStats() {
+ const response = await fetch("/stats/data");
+ const data = await response.json();
+ renderLists(data);
+ renderHeatmap(data.films_per_day_365);
+ await renderMap(data);
+ }
+
+ bootStats();
+ </script>
+{% endblock %}