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