Skip to content
SlashClone

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.

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