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
+153 -39

No files matched your search

+66 -14
View File
@@ -247,10 +247,16 @@ def api_events(evt: dict = Body(...), request: Request = None):
@app.get("/api/stats")
def api_stats(request: Request, days: int = Query(30, ge=1, le=365)):
def api_stats(request: Request, days: int = Query(30, ge=1, le=365),
slug: str = Query(None)):
u = request.state.user
since = time.time() - days * 86400
c = db.connect()
if slug:
positions = c.execute("SELECT ts, payload FROM events WHERE user=? AND type='position' "
"AND ts>=? AND json_extract(payload, '$.slug_id')=? ORDER BY ts",
(u, since, slug)).fetchall()
else:
positions = c.execute("SELECT ts, payload FROM events WHERE user=? AND type='position' "
"AND ts>=? ORDER BY ts", (u, since)).fetchall()
plays = [r[0] for r in c.execute("SELECT payload FROM events WHERE user=? "
@@ -260,43 +266,87 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365)):
"ORDER BY ts", (u, since))]
n_errors = c.execute("SELECT COUNT(*) FROM events WHERE user=? AND type='error' "
"AND ts>=?", (u, since)).fetchone()[0]
days_active = c.execute(
"SELECT COUNT(DISTINCT CAST(ts/86400 AS INTEGER)) FROM events "
"WHERE user=? AND ts>=?", (u, since)).fetchone()[0]
day_set = [r[0] for r in c.execute(
"SELECT DISTINCT CAST(ts/86400 AS INTEGER) FROM events WHERE user=? AND ts>=? "
"ORDER BY 1", (u, since))]
c.close()
days_active = len(day_set)
best = streak = 0 # max consecutive-day run (streak)
for i, d in enumerate(day_set):
streak = streak + 1 if i and d == day_set[i - 1] + 1 else 1
best = max(best, streak)
# ponytail: watch time = sum of positive pos deltas <= 90s per (anime, ep).
# position events fire ~10s apart; a bigger jump is a seek/resume, not viewing
# position events fire ~10s apart; a bigger jump is a seek/resume, not
# viewing; a backward jump >60s within an ep counts as a rewatch
eps = {}
watch_sec = [0.0] * 7 # Mon..Sun
for ts, payload in positions:
e = json.loads(payload)
k = (str(e.get("slug_id")), e.get("ep_id"))
s = eps.setdefault(k, {"title": e.get("title"), "pos": None, "max": 0.0,
"dur": 0.0, "lang": e.get("lang"), "secs": 0.0})
k = (str(e.get("slug_id")), e.get("season_id"), e.get("ep_id"))
s = eps.setdefault(k, {"title": e.get("title"), "poster": e.get("poster"),
"total": e.get("total"), "last_ts": 0.0, "last_ep": None,
"pos": None, "max": 0.0, "dur": 0.0, "lang": e.get("lang"),
"secs": 0.0, "rewinds": 0, "jpn": 0.0, "eng": 0.0,
"days": set()})
p = e.get("pos") or 0
s["days"].add(ts // 86400)
s["last_ts"] = ts
if e.get("ep") is not None:
s["last_ep"] = e["ep"]
if e.get("poster"):
s["poster"] = e["poster"]
if e.get("total"):
s["total"] = e["total"]
if s["pos"] is not None and 0 < p - s["pos"] <= 90:
s["secs"] += p - s["pos"]
s["eng" if e.get("lang") == "eng" else "jpn"] += p - s["pos"]
watch_sec[datetime.fromtimestamp(ts).weekday()] += p - s["pos"]
elif s["pos"] is not None and p < s["pos"] - 60:
s["rewinds"] += 1
s["pos"] = p
s["max"] = max(s["max"], p)
s["dur"] = e.get("dur") or s["dur"]
if e.get("lang"):
s["lang"] = e["lang"]
per_anime, started, completed = {}, 0, 0
for (slug, _ep), s in eps.items():
a = per_anime.setdefault(slug, {"title": s["title"], "minutes": 0.0,
"started": 0, "completed": 0})
per_anime, started, completed, on_hold = {}, 0, 0, 0
for (slug, _season, _ep), s in eps.items():
a = per_anime.setdefault(slug, {"title": s["title"], "poster": s["poster"],
"total": s["total"], "last_seen": 0.0,
"cur_ep": s["last_ep"], "minutes": 0.0,
"started": 0, "completed": 0, "rewatches": 0,
"sub": 0.0, "dub": 0.0, "days": set()})
a["minutes"] += s["secs"] / 60
a["started"] += 1
a["rewatches"] += s["rewinds"]
a["sub"] += s["jpn"]
a["dub"] += s["eng"]
a["days"] |= s["days"]
a["poster"] = a["poster"] or s["poster"]
a["total"] = a["total"] or s["total"]
a["title"] = a["title"] or s["title"]
started += 1
if s["last_ts"] > a["last_seen"]:
a["last_seen"] = s["last_ts"]
a["cur_ep"] = s["last_ep"]
if s["dur"] > 0 and s["max"] >= s["dur"] * 0.95:
a["completed"] += 1
completed += 1
top = sorted(per_anime.values(), key=lambda a: -a["minutes"])[:10]
for a in top:
now = time.time()
for a in per_anime.values():
a["pace"] = round(a["started"] / len(a["days"]), 1)
# ponytail: finished = every started ep completed and (total unknown,
# or all eps done) — heuristic, fine for a v1 chip
fin = a["completed"] == a["started"] and (a["total"] is None
or a["completed"] >= a["total"])
a["status"] = ("finished" if fin else
"ongoing" if now - a["last_seen"] < 7 * 86400 else "hold")
if a["status"] == "hold":
on_hold += 1
a["minutes"] = round(a["minutes"])
a["sub"], a["dub"] = int(a["sub"]), int(a["dub"])
top = sorted(per_anime.values(), key=lambda a: -a["minutes"])[:10]
plays_by_lang = Counter(json.loads(p).get("lang", "jpn") for p in plays)
if not plays_by_lang: # pre-`play`-event data: fall back to position lang
@@ -307,6 +357,8 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365)):
"watch_minutes": round(sum(s["secs"] for s in eps.values()) / 60),
"episodes": {"started": started, "completed": completed},
"days_active": days_active,
"best_streak": best,
"on_hold": on_hold,
"plays": len(plays),
"errors": n_errors,
"by_weekday": [int(x) for x in watch_sec], # seconds, Mon..Sun
+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) {
+14 -4
View File
@@ -58,10 +58,20 @@ h2 { margin: 0.4rem 0; }
.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; }
.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); }
.chip.fin { background: var(--acc); border-color: var(--acc); color: #140b02; font-weight: 600; }
.stat-anime { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: .3rem .8rem;
align-items: center; padding: .55rem; background: var(--card); border: 1px solid var(--bd);
border-radius: 10px; margin: .55rem 0; }
.stat-anime img, .sa-ph { grid-row: span 2; width: 44px; aspect-ratio: 2/3; object-fit: cover;
border-radius: 6px; background: var(--bd); }
.sa-top { display: flex; gap: .8rem; align-items: baseline; min-width: 0; }
.sa-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat-anime .bar { grid-column: 2 / 4; margin-top: 0; }
.card.hold { opacity: .55; }
.detail-prog { margin-top: .1rem; font-size: .85rem; }
.mini-info { display: flex; flex-direction: column; padding: .5rem; }
.mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; }
@media (max-width: 640px) {
+25 -10
View File
@@ -173,27 +173,42 @@ def test_stats(base=BASE):
# fresh user: zeroed stats
d = _json("GET", "/api/stats?days=7", jar=jar, base=base)
assert d["watch_minutes"] == 0 and d["episodes"] == {"started": 0, "completed": 0}
# synthetic viewing: 3 position events an hour ago, pos deltas of 80s each
# (160s watched); one search
# synthetic viewing: ep 1 = 3 position events, pos deltas of 80s each
# (160s watched); ep 2 watched to 1190s of a 1200s dur (completed);
# one search. All an hour ago, jpn.
c = sqlite3.connect(os.path.join(os.environ["ANIWEB_DATA"], "aniweb.db"))
t0 = time.time() - 3600
e = {"slug_id": "stat-a", "ep_id": 900, "title": "Stat Show", "lang": "jpn",
"season_id": 1, "ep": "1", "dur": 1200}
e = {"slug_id": "stat-a", "title": "Stat Show", "lang": "jpn",
"season_id": 1, "dur": 1200, "total": 24, "poster": "https://p/1.jpg"}
for i, pos in enumerate((0.0, 80.0, 160.0)):
c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)",
(t0 + 10 * i, "erin", "position", json.dumps({**e, "pos": pos, "t": t0 + 10 * i})))
(t0 + 10 * i, "erin", "position",
json.dumps({**e, "ep_id": 900, "ep": "1", "pos": pos, "t": t0 + 10 * i})))
for i, pos in enumerate((1100.0, 1190.0)):
c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)",
(t0 + 30 + 10 * i, "erin", "position",
json.dumps({**e, "ep_id": 901, "ep": "2", "pos": pos, "t": t0 + 30 + 10 * i})))
c.execute("INSERT INTO events (ts, user, type, payload) VALUES (?,?,?,?)",
(t0, "erin", "search", json.dumps({"q": "stat anime"})))
c.commit()
c.close()
d = _json("GET", "/api/stats?days=7", jar=jar, base=base)
assert d["watch_minutes"] == 3, d # round(160/60)
assert d["episodes"] == {"started": 1, "completed": 0}
assert d["days_active"] >= 1
assert sum(d["by_weekday"]) == 160 # seconds, Mon..Sun
assert d["top_anime"][0]["title"] == "Stat Show"
assert d["watch_minutes"] == 4, d # round((160+90)/60)
assert d["episodes"] == {"started": 2, "completed": 1}
assert d["days_active"] == 1 and d["best_streak"] == 1
assert d["on_hold"] == 0
assert sum(d["by_weekday"]) == 250 # seconds, Mon..Sun
a0 = d["top_anime"][0]
assert a0["title"] == "Stat Show" and a0["status"] == "ongoing", a0
assert a0["cur_ep"] == "2" and a0["total"] == 24 and a0["poster"] == "https://p/1.jpg"
assert a0["sub"] == 250 and a0["dub"] == 0 and a0["pace"] == 2.0 and a0["rewatches"] == 0
assert d["top_searches"] == ["stat anime"]
assert d["sub_dub"]["jpn"] >= 1 and d["sub_dub"]["eng"] == 0
# slug filter scopes to one anime
ds = _json("GET", "/api/stats?days=7&slug=stat-a", jar=jar, base=base)
assert len(ds["top_anime"]) == 1 and ds["watch_minutes"] == 4
dn = _json("GET", "/api/stats?days=7&slug=none", jar=jar, base=base)
assert dn["top_anime"] == [] and dn["watch_minutes"] == 0
# per-user isolation: alice never watched stat-a
a = login("alice", "alicepw12345", base)
da = _json("GET", "/api/stats?days=7", jar=a, base=base)