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:
1 parent
cebaead521
commit
1261d14945
1 file changed
+140
+140
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>
|
||||
Reference in new issue
Block a user