Add per-user stats: play/error events + /api/stats + ?stats page

- server: 'play' event on successful stream fetch, 'error' on 404 stream
  failures; new POST /api/events for client-reported playback errors
  (only type=error accepted, fields allowlisted + length-capped)
- server: GET /api/stats?days=N — watch time from position-event pos
  deltas (<=90s cap: bigger jumps are seeks), episodes started/completed
  (>=95% of dur), days active, weekday histogram, top anime/searches,
  sub/dub split, error count
- frontend: ?stats page (cards, weekday bars, most-watched, top
  searches) + 'stats' link on home; playerFail now reports the error
- test: test_stats with synthetic viewing data (deltas, isolation, 422)
This commit is contained in:
jozamudi committed 2026-08-24 17:09:27 +00:00
1 parent 202bfa41c3
commit 2f373b9bba
4 files changed
+218 -5

No files matched your search

+67
View File
@@ -63,6 +63,10 @@ function postHistory(entry) {
fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify(entry)}).catch(() => {});
}
function postEvent(ev) {
fetch(BASE + "api/events", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify(ev)}).catch(() => {});
}
const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const timeago = t => {
if (!t) return "";
@@ -110,6 +114,9 @@ function showHome() {
if (history.length > 1) {
const head = el("div", "sec-head");
head.append(el("h2", null, "Recently played"));
const st = el("a", null, "stats \u2192");
st.href = BASE + "?stats";
head.append(st);
if (history.length >= 5) {
const va = el("a", null, "view all \u2192");
va.href = BASE + "?history";
@@ -323,6 +330,64 @@ function histCard(x) {
return c;
}
// ---------- stats (last 30 days, server-computed) ----------
const fmtMin = m => { m = Math.round(m || 0);
return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`; };
const statCard = (label, value) => {
const c = el("div", "card stat");
c.append(el("div", "stat-value", value), el("div", "muted", label));
return c;
};
async function showStats() {
main.innerHTML = "";
main.append(el("h2", null, "Stats \u2014 last 30 days"));
skel(5);
const d = await api("/api/stats?days=30");
main.innerHTML = "";
main.append(el("h2", null, "Stats \u2014 last 30 days"));
const cards = el("div", "grid stats");
cards.append(
statCard("watch time", fmtMin(d.watch_minutes)),
statCard("episodes finished / started", `${d.episodes.completed} / ${d.episodes.started}`),
statCard("days active", String(d.days_active)),
statCard("sub \u00b7 dub plays", `${d.sub_dub.jpn} \u00b7 ${d.sub_dub.eng}`),
statCard("playback errors", String(d.errors)));
main.append(cards);
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);
if (d.top_anime.length) {
const ta = el("section", null);
ta.append(el("h2", null, "Most watched"));
for (const a of d.top_anime) {
const row = el("div", "stat-row");
row.append(el("span", "stat-title", a.title),
el("span", "muted", `${fmtMin(a.minutes)} \u00b7 ${a.completed}/${a.started} ep`));
const b = el("div", "bar small");
const f = el("div", "bar-fill");
f.style.width = (a.started ? Math.round(100 * a.completed / a.started) : 0) + "%";
b.append(f);
row.append(b);
ta.append(row);
}
main.append(ta);
}
if (d.top_searches.length) {
main.append(el("section", null).append(
el("h2", null, "Top searches"),
el("div", "muted", d.top_searches.join(" \u00b7 "))));
}
}
function markActive(b) {
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
b.classList.add("active");
@@ -388,6 +453,7 @@ function playerFail(msg) {
if (!currentEp) return;
playerBroken = true;
const { ep_id, ep_no } = currentEp;
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang});
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
}
@@ -541,6 +607,7 @@ window.addEventListener("pagehide", savePos);
lang: p.get("lang") || "jpn",
});
else if (p.has("history")) await showHistory();
else if (p.has("stats")) await showStats();
else showHome();
} catch (e) { fail(e); }
})();
+13
View File
@@ -49,6 +49,19 @@ h2 { margin: 0.4rem 0; }
.bar.small { height: 4px; margin-top: .4rem; }
.grid.mini { grid-template-columns: repeat(auto-fill, minmax(140px, 170px)); }
.grid.mini .card { cursor: pointer; }
/* stats page (?stats) */
.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); }
.stat-row { display: grid; grid-template-columns: minmax(0, 1fr) auto 90px; gap: .8rem;
align-items: center; margin: .35rem 0; }
.stat-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat-row .bar { margin-top: 0; }
.mini-info { display: flex; flex-direction: column; padding: .5rem; }
.mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; }
@media (max-width: 640px) {