Files
site/stats.html
T

141 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:">
<title>g*ndora · stats</title>
<link rel="icon" href="favicon.svg">
<meta name="theme-color" content="#0b0b0e">
<style>
:root{
--bg:#0b0b0e; --ink:#ededf0; --dim:#8a8a96; --line:#232329; --card:#121216;
--font:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --sans:-apple-system,"Inter",system-ui,sans-serif; color-scheme:dark;
}
:root[data-theme="light"]{--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; color-scheme:light}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; color-scheme:light}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.5 var(--font);padding:16px 16px calc(24px + env(safe-area-inset-bottom));max-width:720px;margin-inline:auto}
header{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:8px 0 18px}
header a{color:var(--dim);text-decoration:none}header a:hover{color:var(--ink)}
h1{font:600 18px var(--font);margin:0;letter-spacing:.02em}
h2{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:22px 0 8px;text-transform:uppercase}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.tile{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 12px}
.tile b{display:block;font:600 20px var(--font);letter-spacing:-.01em}
.tile span{color:var(--dim);font-size:11px}
table{width:100%;border-collapse:collapse;font-size:12px}
td,th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--dim);font-weight:600;font-size:10px;letter-spacing:.14em;text-transform:uppercase}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
.bars{display:flex;align-items:flex-end;gap:3px;height:72px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px}
.bars i{flex:1;background:var(--ink);opacity:.8;border-radius:2px 2px 0 0;min-height:1px}
.bars i[title]{cursor:default}
.row{display:flex;gap:10px;flex-wrap:wrap;color:var(--dim);font-size:11px;margin-top:6px}
.sw{display:inline-block;width:10px;height:10px;border-radius:2px;background:var(--ink);vertical-align:middle;margin-right:4px}
.muted{color:var(--dim)}
button{font:600 11px var(--font);letter-spacing:.14em;background:none;color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:8px 14px;cursor:pointer}
button:hover{background:var(--card)}
.empty{color:var(--dim);padding:40px 0;text-align:center}
</style>
</head>
<body>
<header><h1>g*ndora · stats</h1><a href="./">← back</a></header>
<div id="out"><div class="empty">reading…</div></div>
<script>
const $ = (s) => document.querySelector(s);
try { const th = localStorage.getItem("gandora.theme"); if (th) document.documentElement.dataset.theme = th; } catch {}
const money = (n) => "$" + (n < 0.1 ? n.toFixed(4) : n.toFixed(2));
const mins = (s) => s < 3600 ? Math.round(s / 60) + " min" : (s / 3600).toFixed(1) + " h";
const pct = (a, b) => b ? Math.round(100 * a / b) + "%" : "—";
const q = (arr, p) => { if (!arr.length) return 0; const a = [...arr].sort((x, y) => x - y); return a[Math.min(a.length - 1, Math.floor(p * a.length))]; };
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
function idb(store) {
return new Promise((ok) => { try { const r = indexedDB.open("gandora", 3); r.onsuccess = () => { try { const tx = r.result.transaction(store, "readonly").objectStore(store).getAll(); tx.onsuccess = () => ok(tx.result || []); tx.onerror = () => ok([]); } catch { ok([]); } }; r.onerror = () => ok([]); r.onupgradeneeded = () => {}; } catch { ok([]); } });
}
function tile(v, label) { return `<div class="tile"><b>${v}</b><span>${label}</span></div>`; }
function bars(series, fmt) {
const max = Math.max(1, ...series.map((x) => x.v));
return `<div class="bars">${series.map((x) => `<i style="height:${Math.max(1, 100 * x.v / max)}%" title="${esc(x.l)}: ${esc(fmt ? fmt(x.v) : x.v)}"></i>`).join("")}</div>`;
}
(async () => {
let log = []; try { log = JSON.parse(localStorage.getItem("gandora.log") || "[]"); } catch {}
const [tracks, stations, tapes] = await Promise.all([idb("tracks"), idb("stations"), idb("tapes")]);
const ledger = parseFloat(localStorage.getItem("gandora.cost") || "0");
const cooks = log.filter((e) => e.k === "cook"), plays = log.filter((e) => e.k === "play"), reacts = log.filter((e) => e.k === "react");
const ok = cooks.filter((e) => e.ok), fails = cooks.filter((e) => !e.ok);
const spend = cooks.reduce((a, e) => a + (e.cost || 0), 0);
const listened = plays.reduce((a, e) => a + (e.s || 0), 0);
const made = ok.reduce((a, e) => a + (e.s || 0), 0);
const paid = ok.filter((e) => e.r && !e.r.startsWith("device"));
const lat = paid.map((e) => e.ms).filter(Boolean);
const fb = cooks.filter((e) => e.fb).length;
const h = [];
if (!log.length && !tracks.length) { $("#out").innerHTML = `<div class="empty">nothing yet: the ledger starts with the next track you play</div>`; return; }
h.push(`<h2>overall</h2><div class="tiles">`,
tile(ok.length, "tracks cooked"), tile(mins(made), "audio made"), tile(mins(listened), "listened (plays × length)"),
tile(money(spend), "spent since the ledger began"), tile(money(ledger), "lifetime cost (all time)"),
tile(ok.length ? money(spend / Math.max(1, paid.length)) : "—", "per rendered track"),
tile(stations.length, "stations"), tile(tracks.filter((t) => t.kept).length + " / " + tracks.length, "kept / in library"), tile(tapes.length, "tapes"),
tile(reacts.length, "reactions"), tile(pct(reacts.length, plays.length), "plays that got a reaction"),
`</div>`);
// by day
const days = {};
for (const e of log) { const d = new Date(e.t).toISOString().slice(0, 10); days[d] ||= { cooks: 0, cost: 0, plays: 0, s: 0 }; if (e.k === "cook") { days[d].cooks++; days[d].cost += e.cost || 0; } if (e.k === "play") { days[d].plays++; days[d].s += e.s || 0; } }
const dk = Object.keys(days).sort().slice(-30);
if (dk.length > 1) {
h.push(`<h2>last ${dk.length} days · listening</h2>`, bars(dk.map((d) => ({ l: d, v: days[d].s / 60 })), (v) => Math.round(v) + " min"),
`<div class="row"><span>${dk[0]}</span><span style="margin-left:auto">${dk[dk.length - 1]}</span></div>`,
`<h2>spend by day</h2>`, bars(dk.map((d) => ({ l: d, v: days[d].cost })), money));
}
// renderers
const byR = {};
for (const e of ok) { const r = e.r || "?"; byR[r] ||= { n: 0, cost: 0, s: 0, ms: [] }; byR[r].n++; byR[r].cost += e.cost || 0; byR[r].s += e.s || 0; if (e.ms) byR[r].ms.push(e.ms); }
h.push(`<h2>renderers</h2><table><tr><th>renderer</th><th class="n">tracks</th><th class="n">audio</th><th class="n">cost</th><th class="n">$/min</th><th class="n">cook p50</th><th class="n">p95</th></tr>`);
for (const [r, v] of Object.entries(byR).sort((a, b) => b[1].n - a[1].n))
h.push(`<tr><td>${esc(r)}</td><td class="n">${v.n}</td><td class="n">${mins(v.s)}</td><td class="n">${money(v.cost)}</td><td class="n">${v.s ? money(v.cost / (v.s / 60)) : "—"}</td><td class="n">${v.ms.length ? Math.round(q(v.ms, 0.5) / 1000) + " s" : "—"}</td><td class="n">${v.ms.length ? Math.round(q(v.ms, 0.95) / 1000) + " s" : "—"}</td></tr>`);
h.push(`</table>`);
h.push(`<div class="tiles" style="margin-top:8px">`, tile(lat.length ? Math.round(q(lat, 0.5) / 1000) + " s" : "—", "cook latency p50 (paid renders)"), tile(lat.length ? Math.round(q(lat, 0.95) / 1000) + " s" : "—", "p95"),
tile(pct(fails.length, cooks.length), `failed cooks (${fails.length})`), tile(pct(fb, cooks.length), `fell back to venice (${fb})`), `</div>`);
if (fails.length) { const last = fails.slice(-5).reverse(); h.push(`<div class="row"><span class="muted">recent failures:</span>${last.map((e) => `<span>${esc(e.err || "?")}</span>`).join("")}</div>`); }
// modes & breaks
const byM = {}; for (const e of ok) { const m = e.brk ? "break:" + e.brk : (e.mode || "music"); byM[m] = (byM[m] || 0) + 1; }
h.push(`<h2>what got made</h2><div class="row">${Object.entries(byM).sort((a, b) => b[1] - a[1]).map(([k, v]) => `<span><span class="sw"></span>${esc(k)} ${v}</span>`).join("")}</div>`);
// stations
const byS = {};
for (const e of log) { const s = e.st || "?"; byS[s] ||= { plays: 0, s: 0, react: 0, cost: 0, cooks: 0 }; if (e.k === "play") { byS[s].plays++; byS[s].s += e.s || 0; } if (e.k === "react") byS[s].react++; if (e.k === "cook") { byS[s].cooks++; byS[s].cost += e.cost || 0; } }
const srows = Object.entries(byS).sort((a, b) => b[1].s - a[1].s).slice(0, 15);
h.push(`<h2>stations</h2><table><tr><th>station</th><th class="n">plays</th><th class="n">listened</th><th class="n">reactions</th><th class="n">cost</th></tr>`);
for (const [s, v] of srows) {
const st = stations.find((x) => x.name === s || x.seed === s);
h.push(`<tr><td>${esc(s)}${st && st.mode && st.mode !== "music" ? ` <span class="muted">· ${esc(st.mode)}</span>` : ""}${st && st.djName ? ` <span class="muted">· dj ${esc(st.djName)}</span>` : ""}</td><td class="n">${v.plays}</td><td class="n">${mins(v.s)}</td><td class="n">${v.react}</td><td class="n">${money(v.cost)}</td></tr>`);
}
h.push(`</table>`);
// reactions
const byE = {}; for (const e of reacts) { const k = e.e || "✎ text"; byE[k] = (byE[k] || 0) + 1; }
const erows = Object.entries(byE).sort((a, b) => b[1] - a[1]);
if (erows.length) {
h.push(`<h2>reactions</h2>`, bars(erows.map(([k, v]) => ({ l: k, v })), (v) => v + "×"), `<div class="row">${erows.map(([k, v]) => `<span>${esc(k)} ${v}</span>`).join("")}</div>`);
const whens = reacts.filter((e) => e.at != null && e.at >= 0).map((e) => e.at);
if (whens.length > 4) { const hist = Array.from({ length: 12 }, () => 0); for (const a of whens) hist[Math.min(11, Math.floor(a / 15))]++; h.push(`<h2>when in a track you react</h2>`, bars(hist.map((v, i) => ({ l: `${i * 15}–${i * 15 + 15} s`, v })), (v) => v + "×"), `<div class="row"><span>0:00</span><span style="margin-left:auto">3:00</span></div>`); }
}
// library
const top = [...tracks].filter((t) => t.spec && !t.spec.break).sort((a, b) => (b.plays || 0) - (a.plays || 0)).slice(0, 8);
if (top.length) { h.push(`<h2>most played in the library</h2><table><tr><th>track</th><th>station</th><th class="n">plays</th><th class="n">kept</th></tr>`); for (const t of top) h.push(`<tr><td>${esc(t.spec.title)}</td><td class="muted">${esc(t.spec.station || t.spec.seed || "")}</td><td class="n">${t.plays || 0}</td><td class="n">${t.kept ? "✓" : ""}</td></tr>`); h.push(`</table>`); }
h.push(`<h2>ledger</h2><div class="row"><span>${log.length} lines, on this device only</span><button id="dl">EXPORT JSON</button><button id="clr">CLEAR LEDGER</button></div>`);
$("#out").innerHTML = h.join("");
$("#dl").addEventListener("click", () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([JSON.stringify({ log, stations: stations.map((s) => ({ ...s, history: undefined })), tapes }, null, 1)], { type: "application/json" })); a.download = "gandora-stats.json"; a.click(); });
$("#clr").addEventListener("click", () => { if (confirm("Clear the ledger? The library and stations stay.")) { localStorage.removeItem("gandora.log"); location.reload(); } });
})();
</script>
</body>
</html>