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// Small helpers shared by this kit's scripts. Node built-ins only.
- 2import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
- 3import { dirname } from "node:path";
- 5export function fail(message) {
- 6 process.stderr.write(`Error: ${message}\n`);
- 7 process.exit(1);
- 8}
- 10/** Parses argv into { _: [positional], flag: value }. Flags without a value become true. */
- 11export function parseArgs(argv, booleans = []) {
- 12 const out = { _: [] };
- 13 for (let i = 0; i < argv.length; i++) {
- 14 const a = argv[i];
- 15 if (a.startsWith("--")) {
- 16 const eq = a.indexOf("=");
- 17 if (eq !== -1) out[a.slice(2, eq)] = a.slice(eq + 1);
- 18 else if (booleans.includes(a.slice(2))) out[a.slice(2)] = true;
- 19 else {
- 20 const next = argv[i + 1];
- 21 if (next === undefined || next.startsWith("--")) out[a.slice(2)] = true;
- 22 else {
- 23 out[a.slice(2)] = next;
- 24 i++;
- 25 }
- 26 }
- 27 } else out._.push(a);
- 28 }
- 29 return out;
- 30}
- 32export function readText(path, what = "file") {
- 33 if (!path) fail(`no ${what} given.`);
- 34 if (!existsSync(path)) fail(`${what} not found: ${path}`);
- 35 return readFileSync(path, "utf8").replace(/^\uFEFF/, "");
- 36}
- 38export function readJson(path, what = "JSON file") {
- 39 const text = readText(path, what);
- 40 try {
- 41 return JSON.parse(text);
- 42 } catch (e) {
- 43 return fail(`${path} is not valid JSON (${e.message}).`);
- 44 }
- 45}
- 47/** RFC 4180 CSV parser. Returns { header, rows } where rows are objects keyed by trimmed header. */
- 48export function parseCsv(text) {
- 49 const raw = [];
- 50 let row = [];
- 51 let field = "";
- 52 let quoted = false;
- 53 const pushRow = () => {
- 54 row.push(field);
- 55 field = "";
- 56 if (row.some((v) => v.trim() !== "")) raw.push(row);
- 57 row = [];
- 58 };
- 59 for (let i = 0; i < text.length; i++) {
- 60 const ch = text[i];
- 61 if (quoted) {
- 62 if (ch === '"' && text[i + 1] === '"') {
- 63 field += '"';
- 64 i++;
- 65 } else if (ch === '"') quoted = false;
- 66 else field += ch;
- 67 } else if (ch === '"') quoted = true;
- 68 else if (ch === ",") {
- 69 row.push(field);
- 70 field = "";
- 71 } else if (ch === "\n" || ch === "\r") {
- 72 if (ch === "\r" && text[i + 1] === "\n") i++;
- 73 pushRow();
- 74 } else field += ch;
- 75 }
- 76 if (field !== "" || row.length) pushRow();
- 77 const [head = [], ...body] = raw;
- 78 const header = head.map((h) => h.trim());
- 79 const rows = body.map((r) => {
- 80 const rec = {};
- 81 header.forEach((h, i) => {
- 82 rec[h] = (r[i] ?? "").trim();
- 83 });
- 84 return rec;
- 85 });
- 86 return { header, rows };
- 87}
- 89export function readCsv(path, what = "CSV file", required = []) {
- 90 const { header, rows } = parseCsv(readText(path, what));
- 91 const lower = header.map((h) => h.toLowerCase());
- 92 const missing = required.filter((c) => !lower.includes(c.toLowerCase()));
- 93 if (missing.length)
- 94 fail(
- 95 `${path} is missing column(s): ${missing.join(", ")}. Found: ${header.join(", ") || "(no header)"}.`,
- 96 );
- 97 // Normalize keys to lowercase so "Amount" and "amount" both work.
- 98 return rows.map((r) => {
- 99 const o = {};
- 100 for (const [k, v] of Object.entries(r)) o[k.toLowerCase()] = v;
- 101 return o;
- 102 });
- 103}
- 105export function csvCell(v) {
- 106 const s = v === null || v === undefined ? "" : String(v);
- 107 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
- 108}
- 110export function toCsv(rows, columns) {
- 111 const lines = [columns.map(csvCell).join(",")];
- 112 for (const r of rows) lines.push(columns.map((c) => csvCell(r[c])).join(","));
- 113 return lines.join("\n") + "\n";
- 114}
- 116/** "$1,234.50", "(12.00)", "-3", "" -> number. Empty or junk -> NaN. */
- 117export function num(v) {
- 118 if (v === null || v === undefined) return NaN;
- 119 let s = String(v).trim();
- 120 if (s === "") return NaN;
- 121 let neg = false;
- 122 if (/^\(.*\)$/.test(s)) {
- 123 neg = true;
- 124 s = s.slice(1, -1);
- 125 }
- 126 s = s.replace(/[$€£,\s%]/g, "");
- 127 if (s.endsWith("-")) {
- 128 neg = !neg;
- 129 s = s.slice(0, -1);
- 130 }
- 131 const n = Number(s);
- 132 if (!Number.isFinite(n)) return NaN;
- 133 return neg ? -n : n;
- 134}
- 136export function num0(v) {
- 137 const n = num(v);
- 138 return Number.isNaN(n) ? 0 : n;
- 139}
- 141export function round(n, digits = 2) {
- 142 const f = 10 ** digits;
- 143 return Math.round((n + Number.EPSILON) * f) / f;
- 144}
- 146export function money(n, digits = 0) {
- 147 if (!Number.isFinite(n)) return "n/a";
- 148 const s = Math.abs(n).toLocaleString("en-US", {
- 149 minimumFractionDigits: digits,
- 150 maximumFractionDigits: digits,
- 151 });
- 152 return `${n < 0 ? "-" : ""}$${s}`;
- 153}
- 155export function pct(n, digits = 1) {
- 156 if (!Number.isFinite(n)) return "n/a";
- 157 return `${(n * 100).toFixed(digits)}%`;
- 158}
- 160export function writeOut(path, content) {
- 161 mkdirSync(dirname(path), { recursive: true });
- 162 writeFileSync(path, content);
- 163}
- 165/** Accepts 2026-10-03, 2026-10-03T12:00:00Z, 10/03/2026, or unix seconds. Returns a Date (UTC) or null. */
- 166export function parseDate(v) {
- 167 if (v === null || v === undefined) return null;
- 168 const s = String(v).trim();
- 169 if (!s) return null;
- 170 if (/^\d{9,10}$/.test(s)) return new Date(Number(s) * 1000);
- 171 if (/^\d{12,13}$/.test(s)) return new Date(Number(s));
- 172 let m = s.match(/^(\d{4})-(\d{2})(?:-(\d{2}))?$/);
- 173 if (m) return new Date(Date.UTC(+m[1], +m[2] - 1, m[3] ? +m[3] : 1));
- 174 m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
- 175 if (m) {
- 176 const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
- 177 return new Date(Date.UTC(y, +m[1] - 1, +m[2]));
- 178 }
- 179 const d = new Date(s);
- 180 return Number.isNaN(d.getTime()) ? null : d;
- 181}
- 183export const DAY_MS = 86400000;
- 185export function dayKey(d) {
- 186 return d.toISOString().slice(0, 10);
- 187}
- 189export function monthKey(d) {
- 190 return d.toISOString().slice(0, 7);
- 191}
- 193/** Monday of the ISO week containing d, as YYYY-MM-DD. */
- 194export function weekKey(d) {
- 195 const day = (d.getUTCDay() + 6) % 7;
- 196 return dayKey(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - day)));
- 197}
- 199export function addMonths(key, n) {
- 200 const [y, m] = key.split("-").map(Number);
- 201 const d = new Date(Date.UTC(y, m - 1 + n, 1));
- 202 return monthKey(d);
- 203}
- 205export function monthsBetween(a, b) {
- 206 const [ya, ma] = a.split("-").map(Number);
- 207 const [yb, mb] = b.split("-").map(Number);
- 208 return (yb - ya) * 12 + (mb - ma);
- 209}
- 211export function median(values) {
- 212 const v = values.filter(Number.isFinite).sort((a, b) => a - b);
- 213 if (!v.length) return NaN;
- 214 const mid = Math.floor(v.length / 2);
- 215 return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
- 216}
- 218export function sum(values) {
- 219 return values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0);
- 220}
- 222export function mdTable(columns, rows) {
- 223 const head = `| ${columns.join(" | ")} |`;
- 224 const sep = `| ${columns.map(() => "---").join(" | ")} |`;
- 225 const body = rows.map((r) => `| ${r.map((c) => String(c).replace(/\|/g, "/")).join(" | ")} |`);
- 226 return [head, sep, ...body].join("\n");
- 227}
- 229export function escapeHtml(s) {
- 230 return String(s)
- 231 .replace(/&/g, "&")
- 232 .replace(/</g, "<")
- 233 .replace(/>/g, ">")
- 234 .replace(/"/g, """);
- 235}
