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

+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)