Weekly forecast rollup
@revopslab/weekly-forecast-rollup
Roll an opportunity export from HubSpot, Salesforce, or any CRM into closed won, commit, best case, and pipeline by rep and team for the quarter, then compare with last week: what moved, which deals slipped or were pulled in, upgrades, downgrades, new, won, lost, and dropped deals.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// weekly-forecast-rollup: roll an opportunity export up into closed won, commit,
- 3// best case, and pipeline by rep and team for a period, and compare with last
- 4// week's export: totals that moved, slipped deals, pulled-in deals, category
- 5// changes, new, won, lost, and dropped deals. Node 22, built-ins only.
- 6//
- 7// Usage:
- 8// node scripts/forecast-rollup.mjs <this-week.csv> [last-week.csv]
- 9// --period-start YYYY-MM-DD --period-end YYYY-MM-DD
- 10// [--out out] [--name forecast] [--map field=Column Name]...
- 12import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
- 13import { join } from "node:path";
- 15function fail(msg) {
- 16 console.error(`weekly-forecast-rollup: ${msg}`);
- 17 process.exit(1);
- 18}
- 20const args = process.argv.slice(2);
- 21const opts = { out: "out", name: "forecast", start: null, end: null, map: {} };
- 22const pos = [];
- 23for (let i = 0; i < args.length; i++) {
- 24 const a = args[i];
- 25 if (a === "--out") opts.out = args[++i];
- 26 else if (a === "--name") opts.name = args[++i];
- 27 else if (a === "--period-start") opts.start = args[++i];
- 28 else if (a === "--period-end") opts.end = args[++i];
- 29 else if (a === "--map") {
- 30 const [k, ...v] = String(args[++i]).split("=");
- 31 opts.map[k.trim()] = v.join("=").trim();
- 32 } else if (a === "--help" || a === "-h") {
- 33 console.log(
- 34 "Usage: node scripts/forecast-rollup.mjs <this-week.csv> [last-week.csv] --period-start YYYY-MM-DD --period-end YYYY-MM-DD [--out out] [--name forecast] [--map field=Column]",
- 35 );
- 36 process.exit(0);
- 37 } else pos.push(a);
- 38}
- 39if (pos.length < 1 || pos.length > 2) fail("give this week's export and, optionally, last week's. Try --help.");
- 40if (!opts.start || !opts.end) fail("--period-start and --period-end are required (for example the quarter: 2026-10-01 and 2026-12-31)");
- 42// ---------- CSV ----------
- 43function parseCsv(text) {
- 44 if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
- 45 const rows = [];
- 46 let row = [];
- 47 let field = "";
- 48 let q = false;
- 49 for (let i = 0; i < text.length; i++) {
- 50 const c = text[i];
- 51 if (q) {
- 52 if (c === '"') {
- 53 if (text[i + 1] === '"') {
- 54 field += '"';
- 55 i++;
- 56 } else q = false;
- 57 } else field += c;
- 58 } else if (c === '"') q = true;
- 59 else if (c === ",") {
- 60 row.push(field);
- 61 field = "";
- 62 } else if (c === "\n" || c === "\r") {
- 63 if (c === "\r" && text[i + 1] === "\n") i++;
- 64 row.push(field);
- 65 rows.push(row);
- 66 row = [];
- 67 field = "";
- 68 } else field += c;
- 69 }
- 70 if (field !== "" || row.length) {
- 71 row.push(field);
- 72 rows.push(row);
- 73 }
- 74 const data = rows.filter((r) => r.some((v) => v.trim() !== ""));
- 75 if (!data.length) return { header: [], records: [] };
- 76 const header = data.shift().map((h) => h.trim());
- 77 return { header, records: data.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? "").trim()]))) };
- 78}
- 80const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
- 81const ALIASES = {
- 82 id: ["id", "opportunity_id", "deal_id", "record_id", "hs_object_id", "opportunity_id_18"],
- 83 name: ["name", "opportunity_name", "deal_name", "dealname"],
- 84 owner: ["owner", "opportunity_owner", "deal_owner", "owner_name", "hubspot_owner", "rep", "account_executive"],
- 85 team: ["team", "sales_team", "owner_team", "owner_role", "region", "segment"],
- 86 amount: ["amount", "amount_converted", "deal_amount", "amount_usd", "amount_in_company_currency", "arr", "acv"],
- 87 close_date: ["close_date", "closedate", "expected_close_date"],
- 88 stage: ["stage", "stage_name", "deal_stage", "dealstage"],
- 89 forecast_category: ["forecast_category", "forecastcategory", "forecast_category_name", "forecastcategoryname", "hs_manual_forecast_category"],
- 90};
- 91function resolveColumns(header, file) {
- 92 const byNorm = new Map(header.map((h) => [norm(h), h]));
- 93 const cols = {};
- 94 for (const [field, list] of Object.entries(ALIASES)) {
- 95 if (opts.map[field]) {
- 96 if (!header.includes(opts.map[field])) fail(`${file}: --map ${field}=${opts.map[field]} names a column that does not exist`);
- 97 cols[field] = opts.map[field];
- 98 continue;
- 99 }
- 100 const hit = list.find((a) => byNorm.has(a));
- 101 if (hit) cols[field] = byNorm.get(hit);
- 102 }
- 103 for (const req of ["id", "amount", "close_date"])
- 104 if (!cols[req]) fail(`${file}: no column found for "${req}". Columns are: ${header.join(", ")}. Use --map ${req}=<column>.`);
- 105 return cols;
- 106}
- 108function parseDate(s) {
- 109 if (!s) return null;
- 110 let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
- 111 if (m) return `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`;
- 112 m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
- 113 if (m) return `${m[3]}-${m[1].padStart(2, "0")}-${m[2].padStart(2, "0")}`;
- 114 return null;
- 115}
- 116if (!parseDate(opts.start) || !parseDate(opts.end)) fail("period dates must be YYYY-MM-DD");
- 117const START = parseDate(opts.start);
- 118const END = parseDate(opts.end);
- 120function category(rawCat, rawStage) {
- 121 const c = norm(rawCat || "");
- 122 const s = norm(rawStage || "");
- 123 if (/closed_?won|^won$/.test(s) || c === "closed" || c === "closed_won" || c === "won") return "closed_won";
- 124 if (/closed_?lost|^lost$/.test(s) || c === "closed_lost" || c === "lost") return "lost";
- 125 if (c.startsWith("omit")) return "omitted";
- 126 if (c === "commit") return "commit";
- 127 if (c === "best_case" || c === "bestcase" || c === "upside") return "best_case";
- 128 if (c === "pipeline") return "pipeline";
- 129 return null;
- 130}
- 132function load(path) {
- 133 let text;
- 134 try {
- 135 text = readFileSync(path, "utf8");
- 136 } catch {
- 137 fail(`cannot read ${path}`);
- 138 }
- 139 const { header, records } = parseCsv(text);
- 140 const cols = resolveColumns(header, path);
- 141 const deals = new Map();
- 142 const problems = [];
- 143 for (const r of records) {
- 144 const id = r[cols.id];
- 145 if (!id) {
- 146 problems.push("row with no id skipped");
- 147 continue;
- 148 }
- 149 const amount = Number(String(r[cols.amount] || "0").replace(/[$,\s]/g, ""));
- 150 const close = parseDate(r[cols.close_date]);
- 151 let cat = category(cols.forecast_category ? r[cols.forecast_category] : "", cols.stage ? r[cols.stage] : "");
- 152 if (!cat) {
- 153 cat = "pipeline";
- 154 problems.push(`${id}: no forecast category, counted as pipeline`);
- 155 }
- 156 if (!Number.isFinite(amount)) problems.push(`${id}: amount "${r[cols.amount]}" is not a number, counted as 0`);
- 157 if (!close) problems.push(`${id}: close date "${r[cols.close_date]}" not understood, left out of the period`);
- 158 deals.set(id, {
- 159 id,
- 160 name: cols.name ? r[cols.name] : id,
- 161 owner: (cols.owner && r[cols.owner]) || "(no owner)",
- 162 team: (cols.team && r[cols.team]) || "(no team)",
- 163 amount: Number.isFinite(amount) ? amount : 0,
- 164 close,
- 165 stage: cols.stage ? r[cols.stage] : "",
- 166 cat,
- 167 inPeriod: Boolean(close && close >= START && close <= END),
- 168 });
- 169 }
- 170 return { deals, cols, problems };
- 171}
- 173const cur = load(pos[0]);
- 174const prev = pos[1] ? load(pos[1]) : null;
- 176const CATS = ["closed_won", "commit", "best_case", "pipeline"];
- 177function rollup(deals, key) {
- 178 const groups = new Map();
- 179 const total = { closed_won: 0, commit: 0, best_case: 0, pipeline: 0, deals: 0 };
- 180 for (const d of deals.values()) {
- 181 if (!d.inPeriod || !CATS.includes(d.cat)) continue;
- 182 const k = key ? d[key] : "total";
- 183 if (!groups.has(k)) groups.set(k, { closed_won: 0, commit: 0, best_case: 0, pipeline: 0, deals: 0 });
- 184 for (const g of [groups.get(k), total]) {
- 185 g[d.cat] += d.amount;
- 186 g.deals++;
- 187 }
- 188 }
- 189 const finish = (g) => ({ ...g, forecast: g.closed_won + g.commit, upside: g.closed_won + g.commit + g.best_case });
- 190 return { total: finish(total), groups: Object.fromEntries([...groups.entries()].sort().map(([k, g]) => [k, finish(g)])) };
- 191}
- 193const byOwner = rollup(cur.deals, "owner");
- 194const byTeam = rollup(cur.deals, "team");
- 195const ownerTeam = {};
- 196for (const d of cur.deals.values()) ownerTeam[d.owner] = d.team;
- 198let wow = null;
- 199let changes = null;
- 200if (prev) {
- 201 const pOwner = rollup(prev.deals, "owner");
- 202 const diffOf = (a, b) => Object.fromEntries(["closed_won", "commit", "best_case", "pipeline", "forecast", "upside"].map((k) => [k, (a?.[k] || 0) - (b?.[k] || 0)]));
- 203 const owners = [...new Set([...Object.keys(byOwner.groups), ...Object.keys(pOwner.groups)])].sort();
- 204 wow = {
- 205 total: { last_week: pOwner.total, this_week: byOwner.total, change: diffOf(byOwner.total, pOwner.total) },
- 206 by_owner: Object.fromEntries(owners.map((o) => [o, diffOf(byOwner.groups[o], pOwner.groups[o])])),
- 207 };
- 208 const RANK = { pipeline: 1, best_case: 2, commit: 3, closed_won: 4 };
- 209 changes = { new: [], won: [], lost: [], slipped: [], pulled_in: [], upgraded: [], downgraded: [], amount_changed: [], dropped: [] };
- 210 for (const d of cur.deals.values()) {
- 211 const p = prev.deals.get(d.id);
- 212 const brief = { id: d.id, name: d.name, owner: d.owner, amount: d.amount, close_date: d.close, category: d.cat };
- 213 if (!p) {
- 214 if (d.inPeriod && d.cat !== "lost" && d.cat !== "omitted") changes.new.push(brief);
- 215 continue;
- 216 }
- 217 if (!p.inPeriod && !d.inPeriod) continue;
- 218 if (d.cat === "closed_won" && p.cat !== "closed_won") changes.won.push(brief);
- 219 else if ((d.cat === "lost" || d.cat === "omitted") && !(p.cat === "lost" || p.cat === "omitted"))
- 220 changes.lost.push({ ...brief, was_category: p.cat });
- 221 else {
- 222 if (d.close && p.close && d.close > p.close)
- 223 changes.slipped.push({ ...brief, was_close_date: p.close, out_of_period: p.inPeriod && !d.inPeriod });
- 224 if (d.close && p.close && d.close < p.close) changes.pulled_in.push({ ...brief, was_close_date: p.close, into_period: !p.inPeriod && d.inPeriod });
- 225 if (RANK[d.cat] && RANK[p.cat] && RANK[d.cat] > RANK[p.cat]) changes.upgraded.push({ ...brief, was_category: p.cat });
- 226 if (RANK[d.cat] && RANK[p.cat] && RANK[d.cat] < RANK[p.cat]) changes.downgraded.push({ ...brief, was_category: p.cat });
- 227 }
- 228 if (Math.abs(d.amount - p.amount) >= 0.005) changes.amount_changed.push({ ...brief, was_amount: p.amount, change: d.amount - p.amount });
- 229 }
- 230 for (const p of prev.deals.values())
- 231 if (!cur.deals.has(p.id) && p.inPeriod && CATS.includes(p.cat))
- 232 changes.dropped.push({ id: p.id, name: p.name, owner: p.owner, amount: p.amount, close_date: p.close, category: p.cat });
- 233}
- 235const result = {
- 236 period: { start: START, end: END },
- 237 columns_used: cur.cols,
- 238 totals: byOwner.total,
- 239 by_team: byTeam.groups,
- 240 by_owner: byOwner.groups,
- 241 owner_team: ownerTeam,
- 242 week_over_week: wow,
- 243 changes,
- 244 data_problems: [...cur.problems, ...(prev ? prev.problems.map((p) => `last week: ${p}`) : [])],
- 245};
- 247// ---------- markdown ----------
- 248const money = (n) => (n < 0 ? "-" : "") + "$" + Math.round(Math.abs(n)).toLocaleString("en-US");
- 249const delta = (n) => (n === 0 ? "0" : (n > 0 ? "+" : "-") + money(Math.abs(n)).replace("-", ""));
- 250const md = [`# Forecast rollup, ${START} to ${END}`, ""];
- 251const t = byOwner.total;
- 252md.push(`**Closed won ${money(t.closed_won)} | Commit ${money(t.commit)} | Forecast (won + commit) ${money(t.forecast)} | Best case ${money(t.best_case)} | Upside ${money(t.upside)} | Pipeline ${money(t.pipeline)}**`, "");
- 253if (wow) md.push(`Week over week: forecast ${delta(wow.total.change.forecast)}, best case ${delta(wow.total.change.best_case)}, pipeline ${delta(wow.total.change.pipeline)}.`, "");
- 254const table = (title, groups, extra) => {
- 255 const label = title.split(" ").pop();
- 256 md.push(`## ${title}`, "", `| ${label[0].toUpperCase() + label.slice(1)} | Won | Commit | Forecast | Best case | Upside | Pipeline | Deals${extra ? " | Forecast WoW" : ""} |`);
- 257 md.push(`| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |${extra ? " ---: |" : ""}`);
- 258 for (const [k, g] of Object.entries(groups))
- 259 md.push(`| ${k} | ${money(g.closed_won)} | ${money(g.commit)} | ${money(g.forecast)} | ${money(g.best_case)} | ${money(g.upside)} | ${money(g.pipeline)} | ${g.deals} |${extra ? ` ${delta(extra[k]?.forecast || 0)} |` : ""}`);
- 260 md.push("");
- 261};
- 262table("By team", byTeam.groups);
- 263table("By rep", byOwner.groups, wow?.by_owner);
- 264if (changes) {
- 265 const section = (title, list, fmt) => {
- 266 if (!list.length) return;
- 267 md.push(`## ${title} (${list.length})`, "");
- 268 for (const d of list) md.push(`- ${fmt(d)}`);
- 269 md.push("");
- 270 };
- 271 const who = (d) => `${d.name} (${d.owner}, ${money(d.amount)})`;
- 272 section("Slipped", changes.slipped, (d) => `${who(d)}: ${d.was_close_date} to ${d.close_date}${d.out_of_period ? ", OUT OF PERIOD" : ""}`);
- 273 section("Downgraded", changes.downgraded, (d) => `${who(d)}: ${d.was_category} to ${d.category}`);
- 274 section("Lost", changes.lost, (d) => `${who(d)}: was ${d.was_category}`);
- 275 section("Dropped from the export", changes.dropped, (d) => `${who(d)}: was ${d.category}, closing ${d.close_date}`);
- 276 section("Won", changes.won, (d) => who(d));
- 277 section("Upgraded", changes.upgraded, (d) => `${who(d)}: ${d.was_category} to ${d.category}`);
- 278 section("Pulled in", changes.pulled_in, (d) => `${who(d)}: ${d.was_close_date} to ${d.close_date}${d.into_period ? ", INTO PERIOD" : ""}`);
- 279 section("New", changes.new, (d) => `${who(d)}: ${d.category}, closing ${d.close_date}`);
- 280 section("Amount changed", changes.amount_changed, (d) => `${who(d)}: ${delta(d.change)} (was ${money(d.was_amount)})`);
- 281}
- 282if (result.data_problems.length) {
- 283 md.push("## Data problems", "");
- 284 for (const p of result.data_problems.slice(0, 50)) md.push(`- ${p}`);
- 285 if (result.data_problems.length > 50) md.push(`- and ${result.data_problems.length - 50} more`);
- 286 md.push("");
- 287}
- 289mkdirSync(opts.out, { recursive: true });
- 290writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(result, null, 2) + "\n");
- 291writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
- 292console.log(
- 293 `Forecast ${money(t.forecast)}, upside ${money(t.upside)}, pipeline ${money(t.pipeline)}${changes ? `; ${changes.slipped.length} slipped, ${changes.downgraded.length} downgraded` : ""}. Wrote ${join(opts.out, opts.name)}.{md,json}`,
- 294);
