Stats UI: per-anime progress rows, status chips, on-hold, streak, detail line

- server: /api/stats top_anime rows enriched — poster, total eps, cur_ep,
  last_seen, status (finished/ongoing/hold heuristic), pace (ep/day),
  sub/dub watch-time split, rewatches (backward jump >60s); new on_hold
  + best_streak top-level keys; optional ?slug= filter (json_extract)
- frontend: 'Most watched' rows = poster + meta + status chip + completion
  bar (ongoing -> hold -> finished order); stat cards swap in on-hold and
  best streak, errors demoted to a one-liner; anime detail page gets a
  progress line (E12/24 · 4h 30m · 2.1 ep/day · status); history cards
  dim + label 'on hold' after 7 days; savePos now includes total eps
- css: .chip, .stat-anime rows, .card.hold, .detail-prog; drop dead .stat-row
- test: test_stats asserts the new fields + slug filter
This commit is contained in:
jozamudi committed 2026-08-24 17:30:01 +00:00
1 parent 2f373b9bba
commit 1be3f4f307
4 files changed
+155 -41

No files matched your search

+48 -11
View File
@@ -207,6 +207,11 @@ async function showAnime(slug_id, opts = {}) {
const crumb = el("a", "crumb", "\u2190 home");
crumb.href = BASE;
head.append(crumb, el("h2", null, detail.name));
const progLine = el("div", "muted detail-prog");
head.append(progLine);
api(`/api/stats?days=30&slug=${encodeURIComponent(slug_id)}`)
.then(d => { if (d.top_anime[0]) progLine.textContent = animeLine(d.top_anime[0]); })
.catch(() => {});
main.append(head);
const player = el("div", "player");
@@ -303,7 +308,8 @@ async function showHistory() {
}
function histCard(x) {
const c = el("div", "card hist");
const hold = x.t && Date.now() / 1000 - x.t > 7 * 86400;
const c = el("div", "card hist" + (hold ? " hold" : ""));
c.onclick = () => location.href = histHref(x);
if (x.poster) {
const i = el("img"); i.src = x.poster; i.alt = "";
@@ -311,7 +317,8 @@ function histCard(x) {
}
const info = el("div", "mini-info");
info.append(el("div", "card-name", x.title),
el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}`));
el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}` +
(hold ? " \u00b7 on hold" : "")));
const pb = bar(x, "small");
if (pb) info.append(pb);
c.append(info);
@@ -338,6 +345,21 @@ const statCard = (label, value) => {
c.append(el("div", "stat-value", value), el("div", "muted", label));
return c;
};
const holdDays = a => Math.max(1, Math.round((Date.now() / 1000 - a.last_seen) / 86400));
const statusLabel = a => a.status === "finished" ? "\u2713 finished"
: a.status === "ongoing" ? "\u25b6 ongoing" : `\u23f8 on hold \u00b7 ${holdDays(a)}d`;
const animeMeta = a => {
const bits = [`E${a.cur_ep}${a.total ? "/" + a.total : ""}`, fmtMin(a.minutes)];
if (a.pace > 0) bits.push(`${a.pace} ep/day`);
const tot = (a.sub || 0) + (a.dub || 0);
if (tot) {
const pct = Math.round(100 * (a.sub || 0) / tot);
bits.push(pct >= 90 ? "sub" : pct <= 10 ? "dub" : `sub ${pct}%`);
}
if (a.rewatches > 0) bits.push(`\u21ba ${a.rewatches} rewatch${a.rewatches > 1 ? "s" : ""}`);
return bits.join(" \u00b7 ");
};
const animeLine = a => `${animeMeta(a)} \u00b7 ${statusLabel(a)}`;
async function showStats() {
main.innerHTML = "";
main.append(el("h2", null, "Stats \u2014 last 30 days"));
@@ -350,9 +372,11 @@ async function showStats() {
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)));
statCard("on hold", `${d.on_hold} anime`),
statCard("best streak", `${d.best_streak} days`),
statCard("sub \u00b7 dub plays", `${d.sub_dub.jpn} \u00b7 ${d.sub_dub.eng}`));
main.append(cards);
if (d.errors > 0) main.append(el("p", "muted", `${d.errors} playback error${d.errors > 1 ? "s" : ""} in the last ${d.days} days`));
const WD = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const mx = Math.max(1, ...d.by_weekday);
const wk = el("div", "wk");
@@ -365,16 +389,28 @@ async function showStats() {
wk.append(col);
});
main.append(wk);
if (d.top_anime.length) {
const order = {ongoing: 0, hold: 1, finished: 2};
const list = [...d.top_anime].sort((x, y) =>
order[x.status] - order[y.status] || y.minutes - x.minutes);
if (list.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`));
for (const a of list) {
const row = el("div", "stat-anime");
if (a.poster) {
const i = el("img"); i.src = a.poster; i.alt = "";
row.append(i);
} else row.append(el("div", "sa-ph"));
const t1 = el("div", "sa-top");
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
row.append(t1,
el("span", "chip" + (a.status === "finished" ? " fin" : a.status === "ongoing" ? " on" : ""),
statusLabel(a)));
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) + "%";
const pct = a.total ? Math.min(100, Math.round(100 * a.completed / a.total))
: (a.started ? Math.round(100 * a.completed / a.started) : 0);
f.style.width = pct + "%";
b.append(f);
row.append(b);
ta.append(row);
@@ -561,7 +597,8 @@ function savePos() {
if (!currentEp || currentEp.v.currentTime <= 3) return;
lastSavedPos = currentEp.v.currentTime;
postHistory({...meta(currentEp.v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime});
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime,
...(epsList.length ? {total: epsList.length} : {})});
}
function watchPos(v) {