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:
1 parent
2f373b9bba
commit
1be3f4f307
4 files changed
+155
-41
No files matched your search
+68
-16
@@ -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
|
||||
|
||||
+48
-11
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user