Saas usage dashboard
@usagemetrics/saas-usage-dashboard
Turn a product events CSV (timestamp, account_id, user_id, event) into one self-contained HTML dashboard: DAU, WAU, MAU, stickiness, active accounts, top features, activation rate, weekly retention, and accounts trending down. No external scripts; open it in any browser or attach it to an email.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1// Builds a single self-contained HTML page with inline SVG charts.
- 2// No external scripts, fonts, or images. Light and dark themes follow the viewer's OS setting.
- 3import { escapeHtml as esc } from "./lib.mjs";
- 5export const SERIES = ["var(--s1)", "var(--s2)", "var(--s3)", "var(--s4)", "var(--s5)", "var(--s6)"];
- 7const CSS = `
- 8:root{color-scheme:light;--bg:#f9f9f7;--card:#fcfcfb;--ink:#0b0b0b;--ink2:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--ring:rgba(11,11,11,.10);
- 9--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--s4:#eda100;--s5:#e87ba4;--s6:#6250d6;--good:#0ca30c;--warn:#fab219;--bad:#d03b3b;--up:#006300;
- 10--q1:#cde2fb;--q2:#9ec5f4;--q3:#6da7ec;--q4:#3987e5;--q5:#256abf;--q6:#184f95;--hiink:#ffffff}
- 11@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#0d0d0d;--card:#1a1a19;--ink:#f0efec;--ink2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--axis:#383835;--ring:rgba(255,255,255,.10);
- 12--s1:#3987e5;--s2:#d95926;--s3:#199e70;--s4:#c98500;--s5:#d55181;--s6:#9085e9;--up:#0ca30c;
- 13--q1:#184f95;--q2:#1c5cab;--q3:#256abf;--q4:#3987e5;--q5:#6da7ec;--q6:#9ec5f4;--hiink:#0b0b0b}}
- 14*{box-sizing:border-box}
- 15body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
- 16main{max-width:1120px;margin:0 auto;padding:24px 16px 48px}
- 17h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:0 0 4px}
- 18.sub{color:var(--ink2);margin:0 0 20px}.note{color:var(--muted);font-size:12px;margin:6px 0 0}
- 19.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));margin-bottom:16px}
- 20.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:16px}
- 21.card{background:var(--card);border:1px solid var(--ring);border-radius:10px;padding:16px;min-width:0}
- 22.tile .v{font-size:26px;font-weight:600;margin-top:2px}.tile .l{color:var(--ink2);font-size:12px}.tile .h{color:var(--muted);font-size:12px}
- 23svg{display:block;width:100%;height:auto;overflow:visible}
- 24svg text{fill:var(--muted);font-size:11px}
- 25.tw{overflow-x:auto}
- 26table{border-collapse:collapse;width:100%;font-size:13px;font-variant-numeric:tabular-nums}
- 27th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--grid);white-space:nowrap}
- 28th{color:var(--ink2);font-weight:600}td.n,th.n{text-align:right}
- 29.hm td.c{text-align:center;color:var(--ink)}
- 30.flag{display:inline-block;padding:1px 8px;border-radius:999px;font-size:12px;border:1px solid var(--ring)}
- 31.flag.bad::before{content:"\\25BC ";color:var(--bad)}.flag.warn::before{content:"\\25B2 ";color:var(--warn)}.flag.good::before{content:"\\25CF ";color:var(--good)}
- 32.legend{display:flex;flex-wrap:wrap;gap:12px;margin:8px 0 0;color:var(--ink2);font-size:12px}
- 33.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:-1px}
- 34.mark:hover{opacity:.8}
- 35`;
- 37export function page(title, subtitle, body) {
- 38 return `<!doctype html>
- 39<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
- 40<title>${esc(title)}</title><style>${CSS}</style></head>
- 41<body><main><h1>${esc(title)}</h1><p class="sub">${esc(subtitle)}</p>
- 42${body}
- 43</main></body></html>
- 44`;
- 45}
- 47export function tiles(items) {
- 48 return `<div class="tiles">${items
- 49 .map(
- 50 (t) =>
- 51 `<div class="card tile"><div class="l">${esc(t.label)}</div><div class="v">${esc(t.value)}</div>${
- 52 t.hint ? `<div class="h">${esc(t.hint)}</div>` : ""
- 53 }</div>`,
- 54 )
- 55 .join("")}</div>`;
- 56}
- 58export function card(title, inner, note) {
- 59 return `<section class="card"><h2>${esc(title)}</h2>${inner}${note ? `<p class="note">${esc(note)}</p>` : ""}</section>`;
- 60}
- 62function niceMax(v) {
- 63 if (!(v > 0)) return 1;
- 64 const p = 10 ** Math.floor(Math.log10(v));
- 65 for (const m of [1, 2, 2.5, 5, 10]) if (v <= m * p) return m * p;
- 66 return 10 * p;
- 67}
- 69/**
- 70 * Line chart. series: [{ name, values: [number|null] }], labels: x labels (same length).
- 71 * format: value formatter for axis and tooltips.
- 72 */
- 73export function lineChart(labels, series, format = String, { height = 200 } = {}) {
- 74 const W = 640;
- 75 const H = height;
- 76 const L = 48;
- 77 const R = 12;
- 78 const T = 10;
- 79 const B = 24;
- 80 const all = series.flatMap((s) => s.values).filter((v) => Number.isFinite(v));
- 81 const max = niceMax(Math.max(0, ...all));
- 82 const n = labels.length;
- 83 const x = (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1));
- 84 const y = (v) => T + (H - T - B) * (1 - v / max);
- 85 let g = "";
- 86 for (let k = 0; k <= 4; k++) {
- 87 const v = (max * k) / 4;
- 88 g += `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}" stroke="var(--grid)" stroke-width="1"/>`;
- 89 g += `<text x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${esc(format(v))}</text>`;
- 90 }
- 91 const step = Math.max(1, Math.ceil(n / 6));
- 92 for (let i = 0; i < n; i += step)
- 93 g += `<text x="${x(i)}" y="${H - 6}" text-anchor="middle">${esc(labels[i])}</text>`;
- 94 series.forEach((s, si) => {
- 95 const color = SERIES[si % SERIES.length];
- 96 let d = "";
- 97 s.values.forEach((v, i) => {
- 98 if (!Number.isFinite(v)) return;
- 99 d += `${d ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`;
- 100 });
- 101 g += `<path d="${d}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round"/>`;
- 102 s.values.forEach((v, i) => {
- 103 if (!Number.isFinite(v)) return;
- 104 g += `<circle class="mark" cx="${x(i).toFixed(1)}" cy="${y(v).toFixed(1)}" r="6" fill="transparent"><title>${esc(
- 105 `${labels[i]} ${s.name}: ${format(v)}`,
- 106 )}</title></circle>`;
- 107 });
- 108 });
- 109 const legend =
- 110 series.length > 1
- 111 ? `<div class="legend">${series
- 112 .map((s, i) => `<span><i style="background:${SERIES[i % SERIES.length]}"></i>${esc(s.name)}</span>`)
- 113 .join("")}</div>`
- 114 : "";
- 115 return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="line chart">${g}</svg>${legend}`;
- 116}
- 118/** Horizontal bar chart. items: [{ label, value, color? }] */
- 119export function barChart(items, format = String, { color = "var(--s1)" } = {}) {
- 120 const W = 640;
- 121 const rowH = 26;
- 122 const labelW = 170;
- 123 const valueW = 80;
- 124 const H = Math.max(rowH, items.length * rowH);
- 125 const max = Math.max(0, ...items.map((i) => i.value));
- 126 const span = W - labelW - valueW;
- 127 let g = "";
- 128 items.forEach((it, i) => {
- 129 const w = max > 0 ? Math.max(2, (span * Math.max(0, it.value)) / max) : 2;
- 130 const yy = i * rowH;
- 131 const label = it.label.length > 26 ? `${it.label.slice(0, 25)}...` : it.label;
- 132 g += `<text x="${labelW - 8}" y="${yy + 17}" text-anchor="end" style="fill:var(--ink2)">${esc(label)}</text>`;
- 133 g += `<rect class="mark" x="${labelW}" y="${yy + 6}" width="${w.toFixed(1)}" height="${rowH - 12}" rx="3" fill="${
- 134 it.color || color
- 135 }"><title>${esc(`${it.label}: ${format(it.value)}`)}</title></rect>`;
- 136 g += `<text x="${labelW + w + 6}" y="${yy + 17}" style="fill:var(--ink)">${esc(format(it.value))}</text>`;
- 137 });
- 138 return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="bar chart">${g}</svg>`;
- 139}
- 141/** Plain table. columns: [{ label, numeric? }], rows: arrays of already-formatted cells (strings may contain safe HTML if html: true). */
- 142export function table(columns, rows, { html = false } = {}) {
- 143 const th = columns.map((c) => `<th${c.numeric ? ' class="n"' : ""}>${esc(c.label)}</th>`).join("");
- 144 const tr = rows
- 145 .map(
- 146 (r) =>
- 147 `<tr>${r
- 148 .map((cell, i) => `<td${columns[i]?.numeric ? ' class="n"' : ""}>${html ? cell : esc(cell)}</td>`)
- 149 .join("")}</tr>`,
- 150 )
- 151 .join("");
- 152 return `<div class="tw"><table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table></div>`;
- 153}
- 155/** Retention-style heatmap: values 0..1, null for not yet observable. */
- 156export function heatmap(rowLabels, colLabels, values, extraCol) {
- 157 const steps = ["--q1", "--q2", "--q3", "--q4", "--q5", "--q6"];
- 158 const th = `<th>Cohort</th>${extraCol ? `<th class="n">${esc(extraCol.label)}</th>` : ""}${colLabels
- 159 .map((c) => `<th class="n">${esc(c)}</th>`)
- 160 .join("")}`;
- 161 const rows = rowLabels
- 162 .map((rl, ri) => {
- 163 const cells = values[ri]
- 164 .map((v) => {
- 165 if (v === null || v === undefined) return `<td class="c"></td>`;
- 166 const s = steps[Math.min(steps.length - 1, Math.floor(v * steps.length))];
- 167 const ink = v >= 0.5 ? "var(--hiink)" : "var(--ink)";
- 168 return `<td class="c" style="background:var(${s});color:${ink}">${Math.round(v * 100)}%</td>`;
- 169 })
- 170 .join("");
- 171 return `<tr><td>${esc(rl)}</td>${extraCol ? `<td class="n">${esc(extraCol.values[ri])}</td>` : ""}${cells}</tr>`;
- 172 })
- 173 .join("");
- 174 return `<div class="tw"><table class="hm"><thead><tr>${th}</tr></thead><tbody>${rows}</tbody></table></div>`;
- 175}
- 177export function flag(kind, text) {
- 178 return `<span class="flag ${kind}">${esc(text)}</span>`;
- 179}
