From 1be3f4f3078ec7f05428fcfc3122e380d3163297 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Mon, 24 Aug 2026 17:30:01 +0000 Subject: [PATCH] Stats UI: per-anime progress rows, status chips, on-hold, streak, detail line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/app.py | 84 ++++++++++++++++++++++++++++++++++++++--------- static/app.js | 59 ++++++++++++++++++++++++++------- static/style.css | 18 +++++++--- tests/test_api.py | 35 ++++++++++++++------ 4 files changed, 155 insertions(+), 41 deletions(-) diff --git a/server/app.py b/server/app.py index b6024f2..f69f848 100644 --- a/server/app.py +++ b/server/app.py @@ -247,12 +247,18 @@ 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() - positions = c.execute("SELECT ts, payload FROM events WHERE user=? AND type='position' " - "AND ts>=? ORDER BY ts", (u, since)).fetchall() + 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=? " "AND type='play' AND ts>=?", (u, since))] searches = [json.loads(r[0]).get("q") for r in c.execute( @@ -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 diff --git a/static/app.js b/static/app.js index a33ed77..5f292cf 100644 --- a/static/app.js +++ b/static/app.js @@ -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) { diff --git a/static/style.css b/static/style.css index e3c519d..223f70f 100644 --- a/static/style.css +++ b/static/style.css @@ -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) { diff --git a/tests/test_api.py b/tests/test_api.py index c9622eb..facf4d8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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)