diff --git a/server/app.py b/server/app.py index f69f848..0b1f757 100644 --- a/server/app.py +++ b/server/app.py @@ -281,6 +281,7 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365), # viewing; a backward jump >60s within an ep counts as a rewatch eps = {} watch_sec = [0.0] * 7 # Mon..Sun + by_day = {} # local YYYY-MM-DD -> seconds for ts, payload in positions: e = json.loads(payload) k = (str(e.get("slug_id")), e.get("season_id"), e.get("ep_id")) @@ -299,9 +300,13 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365), 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"] + d = p - s["pos"] + s["secs"] += d + s["eng" if e.get("lang") == "eng" else "jpn"] += d + dt = datetime.fromtimestamp(ts) + watch_sec[dt.weekday()] += d + k = dt.strftime("%Y-%m-%d") + by_day[k] = by_day.get(k, 0) + d elif s["pos"] is not None and p < s["pos"] - 60: s["rewinds"] += 1 s["pos"] = p @@ -362,6 +367,7 @@ def api_stats(request: Request, days: int = Query(30, ge=1, le=365), "plays": len(plays), "errors": n_errors, "by_weekday": [int(x) for x in watch_sec], # seconds, Mon..Sun + "by_day": {k: int(v) for k, v in by_day.items()}, # seconds per local day "top_anime": top, "top_searches": [q for q, _ in Counter(searches).most_common(5) if q], "sub_dub": {"jpn": plays_by_lang.get("jpn", 0), "eng": plays_by_lang.get("eng", 0)}, diff --git a/static/app.js b/static/app.js index 8abf09c..9b74774 100644 --- a/static/app.js +++ b/static/app.js @@ -482,24 +482,44 @@ function histBoard(items, limit = 0) { return [chips, grid]; } +function heatMap(byDay, days) { + const today = new Date(); today.setHours(12, 0, 0, 0); + const win = new Date(today); win.setDate(win.getDate() - (days - 1)); + const start = new Date(win); + start.setDate(start.getDate() - (start.getDay() + 6) % 7); // back to Monday + const max = Math.max(1, ...Object.values(byDay || {})); + const box = el("div", "heat"); + for (let d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) { + const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const v = (byDay || {})[k] || 0; + const c = el("i"); + if (d < win) c.className = "none"; + else if (v) { + c.className = "l" + Math.min(4, Math.ceil(v / max * 4)); + c.title = k + " \u00b7 " + fmtMin(v / 60); + } + box.append(c); + } + const lg = el("div", "heat-lg"); + lg.append(el("span", "muted", "less")); + for (const cls of ["", "l1", "l2", "l3", "l4"]) { + const s = el("i"); + if (cls) s.className = cls; + lg.append(s); + } + lg.append(el("span", "muted", "more")); + return [box, lg]; +} + async function showStats() { skel(5); const d = await api("/api/stats?days=30"); main.innerHTML = ""; main.append(el("h2", null, "Stats \u2014 last 30 days")); main.append(statsCards(d)); - const WD = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - const mx = Math.max(1, ...d.by_weekday); - const wk = el("div", "wk"); - d.by_weekday.forEach((s, i) => { - const col = el("div", "wk-col"); - const b = el("div", "wk-bar"); - b.style.height = Math.max(3, Math.round(s / mx * 50)) + "px"; - b.title = fmtMin(s / 60); - col.append(b, el("div", "wk-day", WD[i])); - wk.append(col); - }); - main.append(wk); + const hm = el("section", null); + hm.append(el("h2", null, "Watch time by day"), ...heatMap(d.by_day, d.days)); + main.append(hm); const ta = topAnime(d); if (ta) main.append(ta); if (d.top_searches.length) { diff --git a/static/style.css b/static/style.css index b66e1dd..dde09d6 100644 --- a/static/style.css +++ b/static/style.css @@ -56,10 +56,14 @@ h2 { margin: 0.4rem 0; } .grid.stats { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .card.stat { padding: .8rem; gap: .2rem; } .stat-value { font-size: 1.3rem; font-weight: 700; } -.wk { display: flex; gap: .5rem; align-items: flex-end; margin: .4rem 0 .8rem; } -.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); } +.heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 11px); gap: 3px; width: fit-content; margin: .5rem 0 .3rem; } +.heat i, .heat-lg i { width: 11px; height: 11px; border-radius: 2px; background: var(--bd); } +.heat i.none { background: transparent; } +.heat i.l1, .heat-lg i.l1 { background: color-mix(in srgb, var(--acc) 30%, var(--bd)); } +.heat i.l2, .heat-lg i.l2 { background: color-mix(in srgb, var(--acc) 55%, var(--bd)); } +.heat i.l3, .heat-lg i.l3 { background: color-mix(in srgb, var(--acc) 78%, var(--bd)); } +.heat i.l4, .heat-lg i.l4 { background: var(--acc); } +.heat-lg { display: flex; align-items: center; gap: 3px; font-size: .75rem; color: var(--muted); } .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); } diff --git a/tests/test_api.py b/tests/test_api.py index facf4d8..10d79e2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -198,6 +198,7 @@ def test_stats(base=BASE): assert d["days_active"] == 1 and d["best_streak"] == 1 assert d["on_hold"] == 0 assert sum(d["by_weekday"]) == 250 # seconds, Mon..Sun + assert sum(d["by_day"].values()) == 250 and all(isinstance(v, int) and v > 0 for v in d["by_day"].values()) 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" @@ -207,8 +208,9 @@ def test_stats(base=BASE): # 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 + assert sum(ds["by_day"].values()) == 250 dn = _json("GET", "/api/stats?days=7&slug=none", jar=jar, base=base) - assert dn["top_anime"] == [] and dn["watch_minutes"] == 0 + assert dn["top_anime"] == [] and dn["watch_minutes"] == 0 and dn["by_day"] == {} # per-user isolation: alice never watched stat-a a = login("alice", "alicepw12345", base) da = _json("GET", "/api/stats?days=7", jar=a, base=base)