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#!/usr/bin/env node
- 2// SaaS usage dashboard: events CSV in, one self-contained HTML file out (inline SVG, no external scripts).
- 3// Usage: node scripts/usage-dashboard.mjs <events.csv> [--out out/usage-dashboard.html] [--as-of 2026-10-31]
- 4// [--activation-event created_report] [--activation-days 14] [--ignore login,page_view] [--title "Acme usage"]
- 5// Columns: timestamp, account_id, user_id, event. Extra columns are ignored.
- 6import { DAY_MS, dayKey, fail, parseArgs, parseDate, pct, readCsv, round, weekKey, writeOut } from "./lib.mjs";
- 7import { barChart, card, flag, heatmap, lineChart, page, table, tiles } from "./html.mjs";
- 9const args = parseArgs(process.argv.slice(2));
- 10const input = args._[0];
- 11const outHtml = args.out || "out/usage-dashboard.html";
- 12const outJson = outHtml.replace(/\.html?$/i, "") + ".json";
- 13if (!input) fail("usage: node scripts/usage-dashboard.mjs <events.csv> [--out out/usage-dashboard.html]");
- 14const activationEvent = args["activation-event"] || null;
- 15const activationDays = Number(args["activation-days"] ?? 14);
- 16const ignore = new Set(String(args.ignore ?? "login,logout,session_start,page_view,signup,signed_up").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean));
- 18const raw = readCsv(input, "events CSV", ["timestamp", "account_id", "user_id", "event"]);
- 19const events = [];
- 20let skipped = 0;
- 21for (const r of raw) {
- 22 const d = parseDate(r.timestamp);
- 23 if (!d || !r.user_id || !r.event) {
- 24 skipped++;
- 25 continue;
- 26 }
- 27 events.push({ t: d.getTime(), day: dayKey(d), account: r.account_id || "(no account)", user: r.user_id, event: r.event.toLowerCase() });
- 28}
- 29if (!events.length) fail(`no usable events in ${input}.`);
- 30events.sort((a, b) => a.t - b.t);
- 32const lastDay = args["as-of"] ? dayKey(parseDate(args["as-of"]) || fail("--as-of must be a date.")) : events[events.length - 1].day;
- 33const asOf = Date.parse(lastDay);
- 34const dayOffset = (day) => Math.round((asOf - Date.parse(day)) / DAY_MS); // 0 = as-of day
- 35const inWindow = (e, days) => {
- 36 const o = dayOffset(e.day);
- 37 return o >= 0 && o < days;
- 38};
- 40// Users and accounts active per day
- 41const usersByDay = new Map();
- 42const accountsByDay = new Map();
- 43const firstSeen = new Map();
- 44const userAccount = new Map();
- 45for (const e of events) {
- 46 if (dayOffset(e.day) < 0) continue;
- 47 if (!usersByDay.has(e.day)) usersByDay.set(e.day, new Set());
- 48 usersByDay.get(e.day).add(e.user);
- 49 if (!accountsByDay.has(e.day)) accountsByDay.set(e.day, new Set());
- 50 accountsByDay.get(e.day).add(e.account);
- 51 if (!firstSeen.has(e.user)) firstSeen.set(e.user, e.day);
- 52 userAccount.set(e.user, e.account);
- 53}
- 54const activeUsers = (endOffset, days) => {
- 55 const s = new Set();
- 56 for (let o = endOffset; o < endOffset + days; o++) for (const u of usersByDay.get(dayKey(new Date(asOf - o * DAY_MS))) || []) s.add(u);
- 57 return s;
- 58};
- 59const activeAccounts = (endOffset, days) => {
- 60 const s = new Set();
- 61 for (let o = endOffset; o < endOffset + days; o++) for (const a of accountsByDay.get(dayKey(new Date(asOf - o * DAY_MS))) || []) s.add(a);
- 62 return s;
- 63};
- 65const dau = (usersByDay.get(lastDay) || new Set()).size;
- 66const wau = activeUsers(0, 7).size;
- 67const mau = activeUsers(0, 30).size;
- 68let dauSum = 0;
- 69for (let o = 0; o < 30; o++) dauSum += (usersByDay.get(dayKey(new Date(asOf - o * DAY_MS))) || new Set()).size;
- 70const avgDau30 = dauSum / 30;
- 71const stickiness = mau ? avgDau30 / mau : 0;
- 72const mauPrev = activeUsers(30, 30).size;
- 73const accounts30 = activeAccounts(0, 30).size;
- 74const accountsPrev30 = activeAccounts(30, 30).size;
- 76// Series: daily active users for 90 days, weekly active users and accounts for 12 weeks
- 77const dayLabels = [];
- 78const dauSeries = [];
- 79for (let o = 89; o >= 0; o--) {
- 80 const k = dayKey(new Date(asOf - o * DAY_MS));
- 81 dayLabels.push(k.slice(5));
- 82 dauSeries.push((usersByDay.get(k) || new Set()).size);
- 83}
- 84const weekLabels = [];
- 85const wauSeries = [];
- 86const wacSeries = [];
- 87for (let w = 11; w >= 0; w--) {
- 88 weekLabels.push(dayKey(new Date(asOf - (w * 7 + 6) * DAY_MS)).slice(5));
- 89 wauSeries.push(activeUsers(w * 7, 7).size);
- 90 wacSeries.push(activeAccounts(w * 7, 7).size);
- 91}
- 93// Top features in the last 30 days
- 94const feat = new Map();
- 95for (const e of events) {
- 96 if (!inWindow(e, 30) || ignore.has(e.event)) continue;
- 97 const f = feat.get(e.event) || { event: e.event, count: 0, users: new Set(), accounts: new Set() };
- 98 f.count++;
- 99 f.users.add(e.user);
- 100 f.accounts.add(e.account);
- 101 feat.set(e.event, f);
- 102}
- 103const features = [...feat.values()]
- 104 .map((f) => ({ event: f.event, events: f.count, users: f.users.size, accounts: f.accounts.size, share_of_mau: mau ? round(f.users.size / mau, 4) : 0 }))
- 105 .sort((a, b) => b.users - a.users || b.events - a.events);
- 107// Activation: new users first seen 14..104 days before as-of (so each had the full window to activate)
- 108const userEvents = new Map();
- 109for (const e of events) {
- 110 if (!userEvents.has(e.user)) userEvents.set(e.user, []);
- 111 userEvents.get(e.user).push(e);
- 112}
- 113function activated(user) {
- 114 const start = Date.parse(firstSeen.get(user));
- 115 const end = start + activationDays * DAY_MS;
- 116 const evs = userEvents.get(user).filter((e) => Date.parse(e.day) < end);
- 117 if (activationEvent) return evs.some((e) => e.event === activationEvent.toLowerCase());
- 118 return new Set(evs.map((e) => e.day)).size >= 3;
- 119}
- 120const activationCohorts = new Map();
- 121let actN = 0;
- 122let actYes = 0;
- 123for (const [user, day] of firstSeen) {
- 124 const o = dayOffset(day);
- 125 if (o < activationDays || o >= activationDays + 90) continue;
- 126 const wk = weekKey(new Date(Date.parse(day)));
- 127 const c = activationCohorts.get(wk) || { week: wk, users: 0, activated: 0 };
- 128 c.users++;
- 129 actN++;
- 130 if (activated(user)) {
- 131 c.activated++;
- 132 actYes++;
- 133 }
- 134 activationCohorts.set(wk, c);
- 135}
- 136const activationRate = actN ? actYes / actN : null;
- 137const activationRows = [...activationCohorts.values()].sort((a, b) => (a.week < b.week ? -1 : 1));
- 139// Weekly retention: cohorts by first-seen week, last 8 full cohorts, weeks 0..7
- 140const asOfWeek = weekKey(new Date(asOf));
- 141const weekIndex = (day) => Math.round((Date.parse(weekKey(new Date(Date.parse(day)))) - Date.parse(asOfWeek)) / (7 * DAY_MS));
- 142const cohortUsers = new Map();
- 143for (const [user, day] of firstSeen) {
- 144 const wi = weekIndex(day);
- 145 if (wi < -8 || wi > -1) continue;
- 146 if (!cohortUsers.has(wi)) cohortUsers.set(wi, new Set());
- 147 cohortUsers.get(wi).add(user);
- 148}
- 149const userWeeks = new Map();
- 150for (const e of events) {
- 151 if (dayOffset(e.day) < 0) continue;
- 152 if (!userWeeks.has(e.user)) userWeeks.set(e.user, new Set());
- 153 userWeeks.get(e.user).add(weekIndex(e.day));
- 154}
- 155const cohortKeys = [...cohortUsers.keys()].sort((a, b) => a - b);
- 156const retention = cohortKeys.map((wi) => {
- 157 const users = cohortUsers.get(wi);
- 158 const row = [];
- 159 for (let k = 0; k < 8; k++) {
- 160 const target = wi + k;
- 161 if (target >= 0) row.push(null); // the current week is not complete yet
- 162 else row.push(round([...users].filter((u) => userWeeks.get(u)?.has(target)).length / users.size, 4));
- 163 }
- 164 return { cohort: dayKey(new Date(Date.parse(asOfWeek) + wi * 7 * DAY_MS)), users: users.size, weeks: row };
- 165});
- 167// Accounts trending down: last 28 days vs the 28 before
- 168const acct = new Map();
- 169for (const e of events) {
- 170 const o = dayOffset(e.day);
- 171 if (o < 0 || o >= 56) continue;
- 172 const a = acct.get(e.account) || { account: e.account, recent_events: 0, prior_events: 0, recent_users: new Set(), prior_users: new Set(), last_seen: e.day };
- 173 if (o < 28) {
- 174 a.recent_events++;
- 175 a.recent_users.add(e.user);
- 176 } else {
- 177 a.prior_events++;
- 178 a.prior_users.add(e.user);
- 179 }
- 180 if (e.day > a.last_seen) a.last_seen = e.day;
- 181 acct.set(e.account, a);
- 182}
- 183const trending = [...acct.values()]
- 184 .filter((a) => a.prior_events >= 20)
- 185 .map((a) => {
- 186 const evChange = (a.recent_events - a.prior_events) / a.prior_events;
- 187 const userChange = a.prior_users.size ? (a.recent_users.size - a.prior_users.size) / a.prior_users.size : 0;
- 188 const daysQuiet = dayOffset(a.last_seen);
- 189 return { account: a.account, prior_events: a.prior_events, recent_events: a.recent_events, events_change: round(evChange, 4), prior_users: a.prior_users.size, recent_users: a.recent_users.size, users_change: round(userChange, 4), days_since_last_event: daysQuiet };
- 190 })
- 191 .filter((a) => a.events_change <= -0.3 || a.users_change <= -0.3 || a.days_since_last_event >= 14)
- 192 .sort((a, b) => a.events_change - b.events_change);
- 194const summary = {
- 195 source: input,
- 196 as_of: lastDay,
- 197 events: events.length,
- 198 skipped_rows: skipped,
- 199 users: firstSeen.size,
- 200 accounts: new Set(events.map((e) => e.account)).size,
- 201 dau,
- 202 wau,
- 203 mau,
- 204 avg_dau_30d: round(avgDau30, 2),
- 205 stickiness: round(stickiness, 4),
- 206 mau_change: mauPrev ? round((mau - mauPrev) / mauPrev, 4) : null,
- 207 active_accounts_30d: accounts30,
- 208 active_accounts_prev_30d: accountsPrev30,
- 209 activation: { definition: activationEvent ? `did "${activationEvent}" within ${activationDays} days of first event` : `active on 3+ distinct days within ${activationDays} days of first event`, new_users: actN, activated: actYes, rate: activationRate === null ? null : round(activationRate, 4), by_week: activationRows },
- 210 top_features: features.slice(0, 15),
- 211 retention,
- 212 accounts_trending_down: trending,
- 213};
- 214writeOut(outJson, JSON.stringify(summary, null, 2) + "\n");
- 216// HTML
- 217const n0 = (v) => Math.round(v).toLocaleString("en-US");
- 218const delta = (v) => (v === null ? "" : `${v >= 0 ? "+" : ""}${(v * 100).toFixed(0)}% vs prior 30 days`);
- 219const title = args.title || "Product usage";
- 220const body = [
- 221 tiles([
- 222 { label: "DAU", value: n0(dau), hint: `on ${lastDay}` },
- 223 { label: "WAU", value: n0(wau), hint: "last 7 days" },
- 224 { label: "MAU", value: n0(mau), hint: delta(summary.mau_change) },
- 225 { label: "Stickiness (DAU/MAU)", value: pct(stickiness, 0), hint: `avg DAU ${avgDau30.toFixed(1)}` },
- 226 { label: "Active accounts", value: n0(accounts30), hint: `last 30 days (prior ${accountsPrev30})` },
- 227 { label: "Activation rate", value: activationRate === null ? "n/a" : pct(activationRate, 0), hint: `${actYes} of ${actN} new users` },
- 228 ]),
- 229 `<div class="grid">`,
- 230 card("Daily active users, last 90 days", lineChart(dayLabels, [{ name: "DAU", values: dauSeries }], n0)),
- 231 card("Weekly active users and accounts", lineChart(weekLabels, [{ name: "Active users", values: wauSeries }, { name: "Active accounts", values: wacSeries }], n0), "Each point is the 7 days ending on that week's last day."),
- 232 `</div><div class="grid">`,
- 233 card(
- 234 "Top features, last 30 days (by users)",
- 235 features.length ? barChart(features.slice(0, 10).map((f) => ({ label: f.event, value: f.users })), n0) : "<p>No feature events.</p>",
- 236 `Ignored: ${[...ignore].join(", ") || "nothing"}.`,
- 237 ),
- 238 card(
- 239 "Feature detail",
- 240 table(
- 241 [{ label: "Event" }, { label: "Users", numeric: true }, { label: "% of MAU", numeric: true }, { label: "Accounts", numeric: true }, { label: "Events", numeric: true }],
- 242 features.slice(0, 10).map((f) => [f.event, n0(f.users), pct(f.share_of_mau, 0), n0(f.accounts), n0(f.events)]),
- 243 ),
- 244 ),
- 245 `</div>`,
- 246 card(
- 247 "Weekly retention by first-seen week",
- 248 retention.length ? heatmap(retention.map((r) => r.cohort), Array.from({ length: 8 }, (_, i) => `W${i}`), retention.map((r) => r.weeks), { label: "Users", values: retention.map((r) => String(r.users)) }) : "<p>Not enough history.</p>",
- 249 "Share of each cohort active in each week after their first event. The current, partial week is left blank.",
- 250 ),
- 251 `<div style="height:16px"></div><div class="grid">`,
- 252 card(
- 253 "Activation by signup week",
- 254 table(
- 255 [{ label: "Week" }, { label: "New users", numeric: true }, { label: "Activated", numeric: true }, { label: "Rate", numeric: true }],
- 256 activationRows.map((r) => [r.week, n0(r.users), n0(r.activated), pct(r.users ? r.activated / r.users : 0, 0)]),
- 257 ),
- 258 `Activated = ${summary.activation.definition}.`,
- 259 ),
- 260 card(
- 261 "Accounts trending down",
- 262 trending.length
- 263 ? table(
- 264 [{ label: "Account" }, { label: "Events (prior 28d to last 28d)", numeric: true }, { label: "Change", numeric: true }, { label: "Users", numeric: true }, { label: "Signal" }],
- 265 trending.map((a) => [a.account, `${n0(a.prior_events)} to ${n0(a.recent_events)}`, pct(a.events_change, 0), `${a.prior_users} to ${a.recent_users}`, a.days_since_last_event >= 14 ? flag("bad", `quiet ${a.days_since_last_event} days`) : flag("warn", "usage down")]),
- 266 { html: true },
- 267 )
- 268 : "<p>No account dropped 30% or more.</p>",
- 269 "Accounts with 20+ events in the prior 28 days whose events or active users fell 30% or more, or with no events in 14 days.",
- 270 ),
- 271 `</div>`,
- 272 `<p class="note">Source: ${input.replace(/[<>&"]/g, "")}. ${n0(events.length)} events, ${n0(summary.users)} users, ${n0(summary.accounts)} accounts, as of ${lastDay}.${skipped ? ` Skipped ${skipped} rows.` : ""}</p>`,
- 273].join("\n");
- 274writeOut(outHtml, page(title, `As of ${lastDay}`, body));
- 276process.stdout.write(`Wrote ${outHtml} and ${outJson}\n`);
- 277process.stdout.write(`DAU ${dau}, WAU ${wau}, MAU ${mau}, stickiness ${pct(stickiness, 0)}, activation ${activationRate === null ? "n/a" : pct(activationRate, 0)}, ${trending.length} account(s) trending down.\n`);
