stats.html: your numbers, from the ledger and the library, on the device only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
This commit is contained in:
teahubxyzandClaude Fable 5.1 committed 2026-10-21 08:26:41 +00:00
1 parent cebaead521
commit 1261d14945
1 file changed
+140
+140
View File
@@ -0,0 +1,140 @@
<!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>