diff --git a/static/app.js b/static/app.js index 0f8ba81..8abf09c 100644 --- a/static/app.js +++ b/static/app.js @@ -172,6 +172,23 @@ function showHome() { link("history \u2192", "?history")]); showSec("On hold", hold, hold.length ? [link("history \u2192", "?history")] : []); showSec("Recently finished", done, done.length ? [link("history \u2192", "?history")] : []); + // 5. stats widget (compact, drill-down to ?stats) + const sw = el("div", null); + const sh = el("div", "sec-head"); + sh.append(el("h2", null, "Stats \u00b7 30d"), link("see all \u2192", "?stats")); + const load = el("p", "muted", "loading\u2026"); + sw.append(sh, load); + main.append(sw); + api("/api/stats?days=30").then(d => { + load.remove(); + sw.append(statsCards(d)); + const ta = topAnime(d, 3); + if (ta) sw.append(ta); + }).catch(() => load.remove()); + // 6. history widget (data already loaded, capped at 6) + const hh = el("div", "sec-head"); + hh.append(el("h2", null, "History"), link(`all ${history.length} \u2192`, "?history")); + main.append(hh, ...histBoard(history, 6)); } // ---------- search ---------- @@ -343,23 +360,7 @@ async function showHistory() { main.innerHTML = ""; main.append(el("h2", null, "History")); if (!items.length) { main.append(el("p", "muted", "Nothing here.")); return; } - const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"}; - let active = "all"; - const chips = el("div", "chips"); - const grid = el("div", "grid"); - const draw = () => { - grid.replaceChildren(...items.filter(x => active === "all" || histClass(x) === active).map(histCard)); - for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active); - }; - for (const f of Object.keys(labels)) { - const b = el("button", "chip", labels[f]); - b.dataset.f = f; - b.onclick = () => { active = f; draw(); }; - chips.append(b); - } - main.append(chips); - draw(); - main.append(grid); + main.append(...histBoard(items)); } function histCard(x) { @@ -415,13 +416,9 @@ const animeMeta = a => { 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")); - skel(5); - const d = await api("/api/stats?days=30"); - main.innerHTML = ""; - main.append(el("h2", null, "Stats \u2014 last 30 days")); +// ---------- stats + history renderers (shared by pages and home widgets) ---------- +function statsCards(d) { + const out = el("div", null); const cards = el("div", "grid stats"); cards.append( statCard("watch time", fmtMin(d.watch_minutes)), @@ -430,8 +427,67 @@ async function showStats() { 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`)); + out.append(cards); + if (d.errors > 0) out.append(el("p", "muted", `${d.errors} playback error${d.errors > 1 ? "s" : ""} in the last ${d.days} days`)); + return out; +} +function topAnime(d, n = 0) { + 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 (n) list.length = Math.min(n, list.length); + if (!list.length) return null; + const ta = el("section", null); + ta.append(el("h2", null, "Most watched")); + 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"); + 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); + } + return ta; +} +function histBoard(items, limit = 0) { + const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"}; + let active = "all"; + const chips = el("div", "chips"); + const grid = el("div", "grid"); + const draw = () => { + let f = items.filter(x => active === "all" || histClass(x) === active); + if (limit) f = f.slice(0, limit); + grid.replaceChildren(...f.map(histCard)); + for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active); + }; + for (const f of Object.keys(labels)) { + const b = el("button", "chip", labels[f]); + b.dataset.f = f; + b.onclick = () => { active = f; draw(); }; + chips.append(b); + } + draw(); + return [chips, grid]; +} + +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"); @@ -444,34 +500,8 @@ async function showStats() { wk.append(col); }); main.append(wk); - 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 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"); - 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); - } - main.append(ta); - } + const ta = topAnime(d); + if (ta) main.append(ta); if (d.top_searches.length) { main.append(el("section", null).append( el("h2", null, "Top searches"),