Stats: GitHub-style watch-time heatmap

- api/stats gains by_day: local-date -> seconds (same watch-time
  heuristic as by_weekday)
- ?stats renders a Mon..Sun x week grid (30d window, matches the
  page header) with 4 intensity levels + tooltip per cell + legend;
  replaces the by-weekday bar chart the heatmap subsumes
- test_stats covers by_day (sums, slug-scoped, empty case)
This commit is contained in:
jozamudi committed 2026-08-24 22:24:16 +00:00
1 parent 7d9fab6bd9
commit 7b670cff24
4 files changed
+52 -20

No files matched your search

+32 -12
View File
@@ -482,24 +482,44 @@ function histBoard(items, limit = 0) {
return [chips, grid];
}
function heatMap(byDay, days) {
const today = new Date(); today.setHours(12, 0, 0, 0);
const win = new Date(today); win.setDate(win.getDate() - (days - 1));
const start = new Date(win);
start.setDate(start.getDate() - (start.getDay() + 6) % 7); // back to Monday
const max = Math.max(1, ...Object.values(byDay || {}));
const box = el("div", "heat");
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) {
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const v = (byDay || {})[k] || 0;
const c = el("i");
if (d < win) c.className = "none";
else if (v) {
c.className = "l" + Math.min(4, Math.ceil(v / max * 4));
c.title = k + " \u00b7 " + fmtMin(v / 60);
}
box.append(c);
}
const lg = el("div", "heat-lg");
lg.append(el("span", "muted", "less"));
for (const cls of ["", "l1", "l2", "l3", "l4"]) {
const s = el("i");
if (cls) s.className = cls;
lg.append(s);
}
lg.append(el("span", "muted", "more"));
return [box, lg];
}
async function showStats() {
skel(5);
const d = await api("/api/stats?days=30");
main.innerHTML = "";
main.append(el("h2", null, "Stats \u2014 last 30 days"));
main.append(statsCards(d));
const WD = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const mx = Math.max(1, ...d.by_weekday);
const wk = el("div", "wk");
d.by_weekday.forEach((s, i) => {
const col = el("div", "wk-col");
const b = el("div", "wk-bar");
b.style.height = Math.max(3, Math.round(s / mx * 50)) + "px";
b.title = fmtMin(s / 60);
col.append(b, el("div", "wk-day", WD[i]));
wk.append(col);
});
main.append(wk);
const hm = el("section", null);
hm.append(el("h2", null, "Watch time by day"), ...heatMap(d.by_day, d.days));
main.append(hm);
const ta = topAnime(d);
if (ta) main.append(ta);
if (d.top_searches.length) {
+8 -4
View File
@@ -56,10 +56,14 @@ h2 { margin: 0.4rem 0; }
.grid.stats { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.card.stat { padding: .8rem; gap: .2rem; }
.stat-value { font-size: 1.3rem; font-weight: 700; }
.wk { display: flex; gap: .5rem; align-items: flex-end; margin: .4rem 0 .8rem; }
.wk-col { display: flex; flex-direction: column; align-items: center; gap: .2rem; }
.wk-bar { width: 26px; background: var(--acc); border-radius: 4px; }
.wk-day { font-size: .7rem; color: var(--muted); }
.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 11px); gap: 3px; width: fit-content; margin: .5rem 0 .3rem; }
.heat i, .heat-lg i { width: 11px; height: 11px; border-radius: 2px; background: var(--bd); }
.heat i.none { background: transparent; }
.heat i.l1, .heat-lg i.l1 { background: color-mix(in srgb, var(--acc) 30%, var(--bd)); }
.heat i.l2, .heat-lg i.l2 { background: color-mix(in srgb, var(--acc) 55%, var(--bd)); }
.heat i.l3, .heat-lg i.l3 { background: color-mix(in srgb, var(--acc) 78%, var(--bd)); }
.heat i.l4, .heat-lg i.l4 { background: var(--acc); }
.heat-lg { display: flex; align-items: center; gap: 3px; font-size: .75rem; color: var(--muted); }
.chip { font-size: .7rem; padding: .1rem .5rem; border-radius: 99px; border: 1px solid var(--bd);
color: var(--muted); white-space: nowrap; }
.chip.on { color: var(--acc); border-color: var(--acc); }