@wrangled/cli 0.3.148 → 0.3.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/guide.js CHANGED
@@ -156,6 +156,7 @@ For "how is the funnel doing?", "which campaigns are working?" and "where does t
156
156
  - \`${cli} funnel [--grain day|week|month]\` prints ad spend, leads, booked calls, calls held, sales and net revenue per day, week (starting Monday) or month, with cost per lead, per booked call and per sale and ROAS, then totals for the whole range.
157
157
  - \`${cli} campaigns [--channel <channel>] [--limit <n>]\` prints each campaign's spend, leads, booked calls, sales and revenue, best first (most first-touch revenue, then most spend), then totals over every matching campaign. Each lead and sale is credited to the person's first touch and last touch side by side. \`--limit\` is 1 to 200 (default 50); \`--channel\` is meta, google, affiliate, a utm_source, unknown or recorded_in_crm.
158
158
  - \`${cli} money [--grain day|week|month]\` prints money in (gross, refunds, net, orders), ad spend, affiliate payouts, what is left after ad costs and ROAS per period, then totals for the whole range, money in per payment source (stripe, ghl) and the biggest refund days. Refunds come off on the day they were made, not the day of the sale. \`left_after_ad_costs\` is net less ad spend and affiliate payouts; it is not profit. Its \`roas\` is net over ad spend alone, so it differs from \`${cli} funnel\`'s, which divides by all spend including affiliate payouts.
159
+ - When no payment tool, store or CRM sales are connected but Triple Whale is, \`${cli} money\` takes money in from Triple Whale's store totals and \`${cli} campaigns\` adds the orders, revenue and ROAS Triple Whale credits to each Facebook campaign (\`orders_triple_whale\`, \`revenue_triple_whale\`, \`roas_triple_whale\`), counted as \`${cli} winners\` counts them. Each says so under its table. Triple Whale can credit one order to several ads, so campaign orders can add up to more than the store's. With neither, each report says what is missing and leaves out the columns that would be blank.
159
160
  - Money is never converted or added across currencies: there is one row per currency, and one total per currency. In \`${cli} funnel\`, leads and calls are counted for the whole period and repeat on every currency row, so never add them across its rows. In \`${cli} campaigns\`, each campaign's leads and calls sit on the row of the currency it spends in (or a blank-currency row when it has no spend), so they do add up across rows and totals. Ratios come from the summed numbers, not averages. An empty cell means the source is not connected, or a ratio would divide by 0.
160
161
  - \`${cli} metrics query --metric <name> [--by <dimension>]...\` gives any of the standard numbers by name, split and filtered your way, such as \`--metric spend --metric cost_per_lead --by channel --where channel!=affiliate --sort=-spend\`. It sums the parts before dividing, keeps currencies apart, credits leads and sales by channel or campaign to the first touch (\`--attribution last_touch\` for the last), and prints an empty number, never 0, for a source that is not connected. An unknown name fails with the list of real ones. Use it before writing SQL for a number these reports do not split the way you need.
161
162
  - \`${cli} metrics search "<everyday words>"\` finds the name \`metrics query\` takes from your own words, such as \`"show rate"\`, \`"cost per booked call"\` or \`"first time buyers"\`: each match with what it means, which of the client's sources feed it and whether they are connected, and for a split like channel or campaign up to five of the client's own values. \`${cli} metrics list\` prints every number and split. The client's own confirmed labels and numbers show beside the catalog's, marked "(custom)". Search before guessing a name.
@@ -567,6 +568,7 @@ ${cli} facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--j
567
568
  ${cli} number-alerts [--since <date>] the key numbers that swung far from normal, day by day
568
569
  ${cli} facts propose --file <fact.json> suggest a fact for a person to confirm
569
570
  ${cli} lessons [--status active|retired] [--about ads|pages|funnel|messages|cross_channel] [--dimension <d>] | lessons add --file <lesson.json> | lessons retire <id> --note "<why>" lessons from winners and flops, writing one, retiring one
571
+ ${cli} tests p1 this brand's P1 test ads by verdict, judged on its own sales against its P1 thresholds, with Meta's count beside
570
572
  ${cli} tests [--status <status>] [--funnel <key>] [--limit <n>] | tests show <id> | tests draft --file <test.json> | tests start <id> | tests abandon <id> [--note "<why>"] | tests results <id> | tests decide <id> --decision <d> [--note "<why>"] funnel tests, one change each
571
573
  ${cli} ads tags [--ad <id>] [--status <status>] [--limit <n>] | ads tag --file <tags.json> the marketing tags of ads not made here, and suggesting them
572
574
  ${cli} ads names [--unparsed] Meta ad names and the tags each gives; --unparsed lists those that fit no pattern
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { basename, dirname, extname, join, resolve } from "node:path";
7
7
  import { parseArgs } from "node:util";
8
8
  import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, DOCUMENT_LIMITS, META_IDS_MAX, PROFILE_MESSAGING_KINDS, OUTCOME_PARTS, FACET_WORDS, GOOGLE_ADS_IDS_MAX, IMPORT_NAME_PATTERN, AGENT_TABLE_LIMITS, AGENT_TABLE_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, OUTBOUND_PUSH_MAX, entryTagFlags, streamSigned, } from "./api.js";
9
9
  import { brand, envPrefix } from "./brand.js";
10
+ import { campaignLayout, money, moneyLayout } from "./report-columns.js";
10
11
  import { renderDataMap, renderQuestions } from "./map.js";
11
12
  import { apiSetupPath, parseAuthFlag, parseQueryFlags, setupText, submittedText, testText, tryText, waitingText, } from "./api-setup.js";
12
13
  import { answerRow, batches, ENRICH_EMAIL_USAGE, parsePeople, parseSteps } from "./enrich.js";
@@ -242,7 +243,7 @@ const commands = {
242
243
  const { funnel } = await api(await resolveConnection(options), `/api/v1/reports/funnel${query}`);
243
244
  if (options.format === "json")
244
245
  return printJson({ funnel });
245
- printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], FUNNEL_NUMBERS);
246
+ printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], { numbers: FUNNEL_NUMBERS, before: [], after: [] });
246
247
  printLeftOut(funnel.left_out, "sales, revenue_net and roas");
247
248
  },
248
249
  },
@@ -480,7 +481,7 @@ const commands = {
480
481
  const { money } = await api(await resolveConnection(options), `/api/v1/reports/money${query}`);
481
482
  if (options.format === "json")
482
483
  return printJson({ money });
483
- printReport(`Money by ${money.grain}`, money, ["period", "currency"], MONEY_NUMBERS);
484
+ printReport(`Money by ${money.grain}`, money, ["period", "currency"], moneyLayout(money));
484
485
  if (money.by_source.length > 0)
485
486
  console.log(`\nMoney in by payment source:\n${renderTable(["source", "currency", "gross", "refunds", "net", "orders"], money.by_source)}`);
486
487
  if (money.store_orders_in_stripe?.length) {
@@ -552,7 +553,7 @@ const commands = {
552
553
  if (options.format === "json")
553
554
  return printJson({ campaignResults });
554
555
  const title = campaignResults.channel === null ? "Campaigns, best first" : `${campaignResults.channel} campaigns, best first`;
555
- printReport(title, campaignResults, ["channel", "campaign_id", "campaign_name", "currency"], CAMPAIGN_NUMBERS);
556
+ printReport(title, campaignResults, ["channel", "campaign_id", "campaign_name", "currency"], campaignLayout(campaignResults));
556
557
  printLeftOut(campaignResults.left_out, "the totals (the rows keep it)");
557
558
  },
558
559
  },
@@ -3154,6 +3155,17 @@ const commands = {
3154
3155
  console.log(renderTable(TEST_COLUMNS, tests.map(testRow)));
3155
3156
  },
3156
3157
  },
3158
+ "tests p1": {
3159
+ usage: "tests p1 this brand's P1 test ads by verdict (working, not working, paused, ready for P3, still testing), judged on its own sales against its P1 thresholds",
3160
+ async run(args, options) {
3161
+ if (args.length > 0)
3162
+ throw new CliError("Usage: tests p1", exitCodes.usage);
3163
+ const report = await api(await resolveConnection(options), "/api/v1/p1-tests");
3164
+ if (options.format === "json")
3165
+ return printJson(report);
3166
+ process.stdout.write(p1TestsText(report));
3167
+ },
3168
+ },
3157
3169
  "tests show": {
3158
3170
  usage: "tests show <id|test_id> one funnel test in full",
3159
3171
  async run(args, options) {
@@ -4092,27 +4104,6 @@ function historyText(history) {
4092
4104
  return main + catchingUp.join("");
4093
4105
  }
4094
4106
  const FUNNEL_NUMBERS = ["spend", "leads", "booked_calls", "calls_held", "sales_count", "revenue_net", "new_buyers", "cost_per_lead", "cost_per_booked_call", "cost_per_sale", "cost_per_new_buyer", "roas"];
4095
- const MONEY_NUMBERS = ["gross", "refunds", "net", "orders", "ad_spend", "affiliate_payouts", "left_after_ad_costs", "roas"];
4096
- const CAMPAIGN_NUMBERS = [
4097
- "spend",
4098
- "leads_first_touch",
4099
- "leads_last_touch",
4100
- "booked_calls_first_touch",
4101
- "sales_first_touch",
4102
- "revenue_first_touch",
4103
- "revenue_last_touch",
4104
- "cost_per_lead_first_touch",
4105
- "roas_first_touch",
4106
- "roas_last_touch",
4107
- ];
4108
- function money(value, currency) {
4109
- try {
4110
- return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
4111
- }
4112
- catch {
4113
- return `${value.toFixed(2)} ${currency}`;
4114
- }
4115
- }
4116
4107
  function newIdeasLine({ target, proposedToday, stalled, costPerResult: cpr }, ideasPerDay) {
4117
4108
  const split = `${target} of ${ideasPerDay}${proposedToday > 0 ? `, ${proposedToday} already proposed` : ""}`;
4118
4109
  if (!cpr)
@@ -4247,6 +4238,45 @@ const FIRST_TOUCH_SALE_RULE = "Each sale goes to the Facebook ad that first brou
4247
4238
  * The winners report as plain Markdown: which goal the ads are judged on and what one means, then the dates asked for and,
4248
4239
  * unless they are the same dates, the last 30 days, each currency apart.
4249
4240
  */
4241
+ const P1_GROUPS = [
4242
+ ["working", "Working"],
4243
+ ["not_working", "Not working"],
4244
+ ["paused", "Paused"],
4245
+ ["ready_for_p3", "Ready for P3"],
4246
+ ["testing", "Still testing"],
4247
+ [null, "No verdict"],
4248
+ ];
4249
+ function p1TestsText(report) {
4250
+ const { window, thresholds: t, judgedOn } = report;
4251
+ const lines = [`P1 tests for ${report.brand}, ${window.from} to ${window.to} (${window.days} days), judged on ${judgedOn.label}`];
4252
+ const amount = (n, currency) => (n === null ? "" : currency === null ? n.toFixed(2) : money(n, currency));
4253
+ if (t !== null) {
4254
+ const currency = report.ads.find((ad) => ad.currency !== null)?.currency ?? null;
4255
+ lines.push(`Thresholds: not working after ${amount(t.minSpend, currency)} spent; working at ${amount(t.maxCostPerResult, currency)} or less per ${judgedOn.one}; ` +
4256
+ `ready for P3 at ${t.p3MinResults} or more at ${amount(t.p3MaxCostPerResult, currency)} or less each`);
4257
+ }
4258
+ if (report.missing !== null)
4259
+ lines.push(report.missing);
4260
+ const columns = ["ad", "campaign", "spend", `results (${judgedOn.label})`, "Meta's count", "cost per result", "why"];
4261
+ for (const [verdict, title] of P1_GROUPS) {
4262
+ const ads = report.ads.filter((ad) => ad.verdict === verdict);
4263
+ if (ads.length === 0)
4264
+ continue;
4265
+ const rows = ads.map((ad) => ({
4266
+ ad: `${ad.name} (${ad.adId})`,
4267
+ campaign: ad.campaignName,
4268
+ spend: amount(ad.spend, ad.currency),
4269
+ [columns[3]]: String(ad.results),
4270
+ "Meta's count": ad.metaResults === null ? "" : `${ad.metaResults}${ad.metaDisagrees ? " (differs)" : ""}`,
4271
+ "cost per result": amount(ad.costPerResult, ad.currency),
4272
+ why: ad.because,
4273
+ }));
4274
+ lines.push("", `${title} (${ads.length})`, renderTable(columns, rows));
4275
+ }
4276
+ if (report.ads.length === 0)
4277
+ lines.push("", "No live ads in the P1 campaigns.");
4278
+ return `${lines.join("\n")}\n`;
4279
+ }
4250
4280
  function winnersText(report) {
4251
4281
  const { week, last30Days } = report;
4252
4282
  const words = winnersWords(report);
@@ -4528,13 +4558,19 @@ function printLeftOut(leftOut, figures) {
4528
4558
  const rows = leftOut.flatMap((l) => l.amounts.map((a) => ({ fact: l.fact_id, currency: a.currency, revenue: a.revenue, sales: a.sales, rule: l.summary })));
4529
4559
  console.log(`\nLeft out of ${figures} by confirmed leave-out rules:\n${renderTable(["fact", "currency", "revenue", "sales", "rule"], rows)}`);
4530
4560
  }
4531
- function printReport(title, report, labels, numbers) {
4561
+ function printReport(title, report, labels, { numbers, before, after }) {
4562
+ if (before.length > 0)
4563
+ console.log(`${before.join("\n")}\n`);
4532
4564
  if (report.rows.length === 0)
4533
- return console.log(`No numbers from ${report.from} to ${report.to}.`);
4534
- console.log(`${title}, ${report.from} to ${report.to}:`);
4535
- console.log(renderTable([...labels, ...numbers], report.rows));
4536
- console.log("\nTotals:");
4537
- console.log(renderTable(["currency", ...numbers], report.totals));
4565
+ console.log(`No numbers from ${report.from} to ${report.to}.`);
4566
+ else {
4567
+ console.log(`${title}, ${report.from} to ${report.to}:`);
4568
+ console.log(renderTable([...labels, ...numbers], report.rows));
4569
+ console.log("\nTotals:");
4570
+ console.log(renderTable(["currency", ...numbers], report.totals));
4571
+ }
4572
+ for (const line of after)
4573
+ console.log(line);
4538
4574
  }
4539
4575
  const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
4540
4576
  const FACTS_PATH = "/api/v1/facts";
@@ -0,0 +1,57 @@
1
+ import { brand } from "./brand.js";
2
+ export const MONEY_NUMBERS = ["gross", "refunds", "net", "orders", "ad_spend", "affiliate_payouts", "left_after_ad_costs", "roas"];
3
+ export const CAMPAIGN_NUMBERS = [
4
+ "spend",
5
+ "leads_first_touch",
6
+ "leads_last_touch",
7
+ "booked_calls_first_touch",
8
+ "sales_first_touch",
9
+ "revenue_first_touch",
10
+ "revenue_last_touch",
11
+ "cost_per_lead_first_touch",
12
+ "roas_first_touch",
13
+ "roas_last_touch",
14
+ ];
15
+ const MONEY_IN_NUMBERS = new Set(["gross", "refunds", "net", "orders", "left_after_ad_costs", "roas"]);
16
+ const TRIPLE_WHALE_NUMBERS = ["orders_triple_whale", "revenue_triple_whale", "roas_triple_whale"];
17
+ const NO_CRM_SALES = "no payment tool, store or CRM sales are connected";
18
+ export function money(value, currency) {
19
+ try {
20
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
21
+ }
22
+ catch {
23
+ return `${value.toFixed(2)} ${currency}`;
24
+ }
25
+ }
26
+ /** A server older than this CLI sends no sales_from; its money in is daily_sales'. */
27
+ export function moneyLayout({ sales_from: from }) {
28
+ if (from === undefined || from.source === "daily_sales")
29
+ return { numbers: MONEY_NUMBERS, before: [], after: [] };
30
+ if (from.source === null)
31
+ return { numbers: MONEY_NUMBERS.filter((n) => !MONEY_IN_NUMBERS.has(n)), before: [from.why], after: [] };
32
+ return { numbers: MONEY_NUMBERS, before: [], after: [`\nMoney in is Triple Whale's ${from.detail}, because ${NO_CRM_SALES}.`] };
33
+ }
34
+ const blankEverywhere = (column, rows) => rows.every((row) => row[column] === null || row[column] === undefined);
35
+ /**
36
+ * Without daily_sales, the first- and last-touch columns blank in every row and total are left out, and with Triple
37
+ * Whale its own orders, revenue and return on ad spend are added.
38
+ */
39
+ export function campaignLayout(report) {
40
+ const from = report.sales_from;
41
+ if (from === undefined || from.source === "daily_sales")
42
+ return { numbers: CAMPAIGN_NUMBERS, before: [], after: [] };
43
+ const all = [...report.rows, ...report.totals];
44
+ const touches = CAMPAIGN_NUMBERS.filter((n) => n === "spend" || !blankEverywhere(n, all));
45
+ if (from.source === null)
46
+ return { numbers: touches, before: [from.why], after: [] };
47
+ const others = (report.triple_whale_other_campaigns ?? []).map(({ currency, orders, revenue }) => `Triple Whale credits ${orders} more ${orders === 1 ? "order" : "orders"}${revenue === null || currency === null ? "" : ` (${money(revenue, currency)})`} to Facebook ads that aren't in the ad accounts ${brand.name} is connected to, so they aren't in the rows above.`);
48
+ return {
49
+ numbers: [...touches, ...TRIPLE_WHALE_NUMBERS],
50
+ before: [],
51
+ after: [
52
+ `\nOrders and revenue are what Triple Whale credits to each Facebook campaign (${from.detail}), because ${NO_CRM_SALES}. ` +
53
+ "Triple Whale can credit one order to more than one ad, so these can add up to more than the store's own orders.",
54
+ ...others,
55
+ ],
56
+ };
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrangled/cli",
3
- "version": "0.3.148",
3
+ "version": "0.3.150",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {