@wrangled/cli 0.3.154 → 0.3.156

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
@@ -86,7 +86,7 @@ ${brand.name} writes and sends no emails or texts; the client's own tool (GoHigh
86
86
 
87
87
  ## Which Facebook ads and pages won: winners and flops
88
88
 
89
- \`${cli} winners\` says which Facebook ads won and which flopped, for the dates asked for and for the 30 days ending on their last day, judged on the client's goal. Each client has an ordered list of goals, and winners uses the first one whose data is connected: \`sales\` (first-touch sales from every connected payment source: each sale goes to the Facebook ad that first brought the buyer in, and payments typed into GoHighLevel by hand never count toward an ad), \`sales:triple_whale\` (the orders Triple Whale credits to each ad), \`booked_calls\` (calls booked by someone whose first touch was the ad), \`meta_results\` (Facebook's own count of the result types set on the Meta source, such as applications, \`submit_application_website\`) or \`first_leads\`. With no goals set, a client whose Meta sources count scheduling as their result (such as \`schedule_website\`) defaults to booked calls, then first leads; every other client defaults to Triple Whale's orders, then first-touch sales, then first leads, which is how winners judged before goals could be set. \`${cli} winners goals\` shows the list, whether each goal has data and which one is used now; \`${cli} winners goals set booked_calls first_leads\` sets it, and \`${cli} winners goals reset\` goes back to the default. To judge on Stripe sales, connect Stripe, then put \`sales\` first. The report starts with a sentence saying which goal it judged on, and in the JSON every "sales" field (\`numbers.sales\`, \`costPerSale\`, \`accountCostPerSale\`, \`adSales\`, \`judgedOn: "sales"\`) holds that goal, named in \`goal\`, and every "signups" field the fallback goal, named in \`fallbackGoal\`. An ad with none of the goal that has spent less than twice the account's cost per one is too new to judge on it, so it is judged on the fallback goal, the next connected goal of another kind, and listed apart. Landing pages and ad-and-page pairs are judged on the same goal when it is \`sales\`, \`booked_calls\` or \`first_leads\`, and on first-touch sales otherwise, with first leads for pages too new to judge; \`pagesGoal\` and \`pagesFallbackGoal\` name them in the JSON. Each ad says in a sentence why it is on its list, then its headline and primary text in full (\`headline\` and \`primaryText\` in the JSON), so you need no SQL for its words. When a currency's ads run in two or more Facebook ad accounts, each ad is judged against its own account's cost per sale, and the section says so and lists each account (\`adAccounts\` in the JSON, and \`accountId\` on each ad). Each currency also says how many of its Facebook sales could be tied to a specific ad (\`adSales\` of \`facebookSales\` in the JSON): a sale whose link names no single ad counts toward Facebook but toward no ad. It judges the landing pages the ads sent people to the same way, and each ad on each page as an ad-and-page pair: a page or pair counts only once the page reader has read the page and it got at least 100 visits in the dates, and pairs are listed only for an ad that sent people to two or more pages, or a page that got people from two or more ads, each naming what the same ad cost on its other pages. \`--week <YYYY-MM-DD>\` picks any day of a Monday-to-Sunday week (default: the latest complete week), \`--days <n>\` the last n complete days ending yesterday, and \`--from <YYYY-MM-DD> --to <YYYY-MM-DD>\` any dates; use one of them, at most 90 days. Check as often as suits the client, daily or every few days: a short window holds few sales, so more ads are judged on first leads, and the report says so. \`--top <n>\` how many ads, pages or pairs each list shows (default 5, at most 20), \`--ad-format image|video|carousel\` ranks only ads with that format tag (run \`${cli} ads tag-formats\` first; the account's cost per sale still counts every ad), and \`--format json\` gives the report as data.
89
+ \`${cli} winners\` says which Facebook ads won and which flopped, for the dates asked for and for the 30 days ending on their last day, judged on the client's goal. Each client has an ordered list of goals, and winners uses the first one whose data is connected: \`sales\` (first-touch sales from every connected payment source: each sale goes to the Facebook ad that first brought the buyer in, and payments typed into GoHighLevel by hand never count toward an ad), \`sales:triple_whale\` (the orders Triple Whale credits to each ad), \`booked_calls\` (calls booked by someone whose first touch was the ad), \`meta_results\` (Facebook's own count of the result types set on the Meta source, such as applications, \`submit_application_website\`) or \`first_leads\`. With no goals set, a client whose Meta sources count scheduling as their result (such as \`schedule_website\`) defaults to booked calls, then first leads; every other client defaults to Triple Whale's orders, then first-touch sales, then first leads, which is how winners judged before goals could be set. \`${cli} winners goals\` shows the list, whether each goal has data and which one is used now; \`${cli} winners goals set booked_calls first_leads\` sets it, and \`${cli} winners goals reset\` goes back to the default. To judge on Stripe sales, connect Stripe, then put \`sales\` first. The report starts with a sentence saying which goal it judged on, and in the JSON every "sales" field (\`numbers.sales\`, \`costPerSale\`, \`accountCostPerSale\`, \`adSales\`, \`judgedOn: "sales"\`) holds that goal, named in \`goal\`, and every "signups" field the fallback goal, named in \`fallbackGoal\`. An ad with none of the goal that has spent less than twice the account's cost per one is too new to judge on it, so it is judged on the fallback goal, the next connected goal of another kind, and listed apart. Landing pages and ad-and-page pairs are judged on the same goal when it is \`sales\`, \`booked_calls\` or \`first_leads\`, and on first-touch sales otherwise, with first leads for pages too new to judge; \`pagesGoal\` and \`pagesFallbackGoal\` name them in the JSON. Each ad says in a sentence why it is on its list, then its headline and primary text in full (\`headline\` and \`primaryText\` in the JSON), so you need no SQL for its words. When a currency's ads run in two or more Facebook ad accounts, each ad is judged against its own account's cost per sale, and the section says so and lists each account (\`adAccounts\` in the JSON, and \`accountId\` on each ad). Each currency also says how many of its Facebook sales could be tied to a specific ad (\`adSales\` of \`facebookSales\` in the JSON): a sale whose link names no single ad counts toward Facebook but toward no ad. It judges the landing pages the ads sent people to the same way, and each ad on each page as an ad-and-page pair: a page or pair counts only once the page reader has read the page and it got at least 100 visits in the dates, and pairs are listed only for an ad that sent people to two or more pages, or a page that got people from two or more ads, each naming what the same ad cost on its other pages. \`--week <YYYY-MM-DD>\` picks any day of a Monday-to-Sunday week (default: the latest complete week), \`--days <n>\` the last n days ending today, as money and campaigns count them, and \`--from <YYYY-MM-DD> --to <YYYY-MM-DD>\` any dates; use one of them, at most 90 days. Check as often as suits the client, daily or every few days: a short window holds few sales, so more ads are judged on first leads, and the report says so. \`--top <n>\` how many ads, pages or pairs each list shows (default 5, at most 20), \`--ad-format image|video|carousel\` ranks only ads with that format tag (run \`${cli} ads tag-formats\` first; the account's cost per sale still counts every ad), and \`--format json\` gives the report as data.
90
90
 
91
91
  ## What winners and flops taught: lessons
92
92
 
@@ -328,7 +328,7 @@ To change the map, write the whole map as JSON (\`{"offers": [...], "funnels": [
328
328
 
329
329
  \`--counts-from fix\` (the default) makes the map count for every past date the current map counted, as a fix; \`--counts-from <YYYY-MM-DD>\` makes it a change in the business that counts from that day (today or earlier). Save a fix and a change as two drafts, the fix first. \`--questions @questions.json\` asks a person what only they can settle: an array of \`{question_id, text, kind, evidence, options: [{label, patch}], default_option}\`, where the map already holds the default option and each other option's patch is JSON Patch ops on the map, naming offers, funnels and steps by key (\`/offers/course/matches/-\`). A question whose options all count the same sales and people, or that was answered already, is left out; at most 7 stay open, biggest money at stake first, and the checks add a yes/no question for each weak part no question covers. People answer them in the app; an answer saves the map as a fix, and later drafts keep it. \`${cli} funnels show\` lists the open and answered questions and says whether the map counts as a fix or from a date.
330
330
 
331
- \`${cli} funnels numbers\` counts each step of each funnel in the confirmed map over \`--from\` and \`--to\` (default the last 30 days; \`--funnel <key>\` for one funnel, named by its key, the word in parentheses after its name, never its number): how many reached it, and how many of those who reached the step before in those days moved on to it within the window, with the drop-off and the median days it took. A step whose rules name a person (webinar, GoHighLevel, payments) counts people and follows the same people from step to step; a step that cannot (ad clicks, page visitors) counts events, and its comparison is a rate of counts. A step whose data is not connected says unknown, never 0. So does a page step whose visit data (the visit tag when the workspace has it, else GA4) has never recorded its address. \`${cli} pages results\` can still show views for that page, but those are Facebook's page loads from the ads that link to it, a different count: a Traffic step with an ad_traffic rule counts them, never the page step. Numbers are worked out after each sync by the rules of the map version confirmed at the time, so a renamed step keeps its numbers. For SQL, read \`saved_funnel_steps\`, \`funnel_step_daily\` and \`funnel_conversion\`.
331
+ \`${cli} funnels numbers\` counts each step of each funnel in the confirmed map over \`--from\` and \`--to\` (default the last 30 days; \`--funnel <key>\` for one funnel, named by its key, the word in parentheses after its name, never its number): how many reached it, and how many of those who reached the step before in those days moved on to it within the window, with the drop-off and the median days it took. A step whose rules name a person (webinar, GoHighLevel, payments) counts people and follows the same people from step to step; a step that cannot (ad clicks, page visitors) counts events, and its comparison is a rate of counts. A step whose data is not connected says unknown, never 0. So does a page step whose visit data (the visit tag when the workspace has it, else GA4) has never recorded its address. Beside it, such a step shows the page loads Facebook counted from the ads that link to its pages in those days, the views \`${cli} pages results\` shows. That is a different count from visits, so it never enters the funnel's math or its comparisons; a Traffic step with an ad_traffic rule is the step that counts page loads. Numbers are worked out after each sync by the rules of the map version confirmed at the time, so a renamed step keeps its numbers. For SQL, read \`saved_funnel_steps\`, \`funnel_step_daily\` and \`funnel_conversion\`.
332
332
 
333
333
  \`${cli} funnels leaks\` (\`--funnel <key>\` for one funnel, \`--to <date>\` for the last day) finds where each funnel loses people now: each step's rate from the step before over the last 28 days and the last 7, against its own normal rate over the 56 days before. A step doing worse says about how many fewer people moved on (the recent cohort times the rate it fell by) and what they were worth: an estimate, per currency and never added across them, from the offer prices of this step and the later purchase steps at the recent rates. The worst steps are listed first, by money, then people. A step with fewer than 100 people at the step before in either window says too few to tell and is never ranked; the 7-day rate needs 20. For a step counted by people, the recent cohort is also split by each person's first touch as of the day they reached the step before: channel, then the top 5 Meta campaigns and ads. For a step people take days to reach, shown "within N days", only people who reached the step before at least N days before each window ended count, and only those who moved on within N days, in both windows, and its last 7 days end N days early, so a slow step compares fairly. Start here before writing a conversion test idea.
334
334
 
package/dist/main.js CHANGED
@@ -4229,13 +4229,14 @@ function winnersWindowText(title, window, words) {
4229
4229
  const tied = s.adSales === null || !s.facebookSales ? "" : ` ${tenths(s.adSales)} of ${countOf(s.facebookSales, facebookWords)} could be tied to a specific ad.`;
4230
4230
  const tests = s.tests.map((t) => `- Test ${t.testId}${t.hypothesisId ? ` (idea ${t.hypothesisId})` : ""}: ${counted(t.ads.length, "ad")}, spent ${money(t.spend, s.currency)}, ${countOf(t.sales, primary)}` +
4231
4231
  `${t.costPerSale === null ? "" : `, ${money(t.costPerSale, s.currency)} per ${primary.one}`}\n`);
4232
- const onFallback = fallback === null ? "" : `, ${s.adsJudgedOnSignups} judged on ${fallback.many}`;
4232
+ // With the fallback not connected the too-new ads are not judged at all, and a note says why.
4233
+ const fallbackJudged = fallback !== null && s.signupsSource === "connected";
4234
+ const onFallback = fallback === null ? "" : fallbackJudged ? `, ${s.adsJudgedOnSignups} judged on ${fallback.many}` : `, ${s.adsJudgedOnSignups} not judged (${fallback.many} are not connected)`;
4233
4235
  return (`\nIn ${s.currency}: ${account}.${tied} ${s.adsJudgedOnSales} judged on ${primary.many}${onFallback}.\n` +
4234
4236
  s.notes.map((note) => `${note}\n`).join("") +
4235
4237
  list("Winners", s.winners) +
4236
4238
  list("Flops", s.flops) +
4237
- list(`${s.onSignups.title}: best`, s.onSignups.best) +
4238
- list(`${s.onSignups.title}: worst`, s.onSignups.worst) +
4239
+ (fallback !== null && !fallbackJudged ? "" : list(`${s.onSignups.title}: best`, s.onSignups.best) + list(`${s.onSignups.title}: worst`, s.onSignups.worst)) +
4239
4240
  (tests.length > 0 ? `\nAdzara tests\n${tests.join("")}` : "") +
4240
4241
  pages);
4241
4242
  })
@@ -4346,6 +4347,7 @@ function followUpLines(row) {
4346
4347
  const tags = "tags" in row ? followUpTags(row.tags) : "";
4347
4348
  return `- ${name}: ${sent}, ${engaged}, ${counted(n.salesHelped, "sale")} helped, ${tenths(n.salesLastNudge)} last nudge${n.optedOut > 0 ? `, ${counted(n.optedOut, "opt-out")}` : ""}${tags ? `. Tags: ${tags}` : ""}\n ${row.pickedBecause}\n`;
4348
4349
  }
4350
+ /** One window's follow-up lists, under its dates unless `title` is null (the report has one window, whose dates head the ads). */
4349
4351
  function followUpWindowText(title, window) {
4350
4352
  const list = (heading, rows) => `\n${heading}\n${rows.length === 0 ? "- none\n" : rows.map(followUpLines).join("")}`;
4351
4353
  const claimed = window.toolClaimed.map((m) => {
@@ -4353,7 +4355,7 @@ function followUpWindowText(title, window) {
4353
4355
  const sends = m.toolSends === null ? "" : `, ${tenths(m.toolSends)} sent`;
4354
4356
  return `- ${m.name ?? m.subject ?? "Email"} [${m.itemId}] (${m.platform}): ${revenue} claimed${sends}\n ${m.pickedBecause}\n`;
4355
4357
  });
4356
- return (`\n### ${title}\n` +
4358
+ return ((title === null ? "" : `\n### ${title}\n`) +
4357
4359
  list("Top emails", window.emails.top) +
4358
4360
  list("Bottom emails", window.emails.bottom) +
4359
4361
  list("Top texts", window.texts.top) +
@@ -4367,9 +4369,9 @@ function followUpText(followUp, title) {
4367
4369
  if (followUp.state === "not_connected")
4368
4370
  return "\n## Follow-up emails and texts\n\nNo email or text tool is connected yet, so there are no follow-up messages to judge.\n";
4369
4371
  const { week, last30Days } = followUp;
4370
- return (`\n## Follow-up emails and texts\n\nA sale is helped by a message the buyer clicked or replied to in the ${counted(followUp.creditDays, "day")} before buying. Under ${tenths(followUp.minSends)} sent, a message is judged on clicks or replies, never as a flop.\n` +
4371
- followUpWindowText(title, week) +
4372
- (week.from === last30Days.from && week.to === last30Days.to ? "" : followUpWindowText(`Last 30 days, ${last30Days.from} to ${last30Days.to}`, last30Days)));
4372
+ const once = week.from === last30Days.from && week.to === last30Days.to;
4373
+ return (`\n## Follow-up emails and texts\n\n${once ? `For the same dates as the ads above, ${week.from} to ${week.to}. ` : ""}A sale is helped by a message the buyer clicked or replied to in the ${counted(followUp.creditDays, "day")} before buying. Under ${tenths(followUp.minSends)} sent, a message is judged on clicks or replies, never as a flop.\n` +
4374
+ (once ? followUpWindowText(null, week) : followUpWindowText(title, week) + followUpWindowText(`Last 30 days, ${last30Days.from} to ${last30Days.to}`, last30Days)));
4373
4375
  }
4374
4376
  function printWinnersGoals(goals, format) {
4375
4377
  if (format === "json")
@@ -5097,7 +5099,7 @@ Options:
5097
5099
  --top <n> how many best ads, worst ads, pages and campaigns brief shows, or ads in each winners list (default: 5, at most 20)
5098
5100
  --week <YYYY-MM-DD> any day of the week winners covers, Monday to Sunday (default: the latest complete week); with
5099
5101
  messages brief, the week its winning ads come from, its message numbers the 30 days ending that Sunday
5100
- --days <n> winners: the last n complete days, ending yesterday, instead of a week (at most 90); or give winners
5102
+ --days <n> winners: the last n days, ending today, instead of a week (at most 90); or give winners
5101
5103
  --from and --to for any dates; seo page-two: how many days, ending on Search Console's newest day (default: 90)
5102
5104
  --site <site_url> seo page-two: only this Search Console property, such as sc-domain:example.com
5103
5105
  --min-impressions <n> seo page-two: only searches shown at least n times (default: 100)
package/dist/playbooks.js CHANGED
@@ -748,7 +748,7 @@ Start from the shape that fits, then rename and add steps to match what you read
748
748
 
749
749
  Write \`map.json\` as \`{"offers": [...], "funnels": [...]}\`, in this order.
750
750
 
751
- 1. **Offers**, front end first, numbered by \`stack_position\` from 1. Each has an \`offer_key\` (lowercase, digits and underscores), \`name\`, \`tier\` (\`front_end\` or \`back_end\`), \`delivery\` (course, event, coaching, service, product, membership, webinar, lead_magnet or other), \`price\`, \`currency\`, an optional \`price_note\` (plain words on the price: other prices, payment plans, doubts), and \`matches\`: how a sale is recognised. A match is \`{"platform": ..., "match_type": ..., "value": "..."}\`: platform \`stripe\`, \`ghl\`, \`shopify\` or \`kajabi\`, and match_type \`product_id\`, \`price_id\`, \`amount\` (whole units, with a \`currency\`), \`name_contains\` (any part of the product's name, ignoring case) or \`name_starts_with\` (the start of the product's name, ignoring case). Use \`name_starts_with\` for a family of products whose names begin with one code, such as event tickets named "OCA Dallas" and "OCA Austin"; \`name_contains\` "OCA" would also catch "Local Coaching". Stripe charges carry no product, so match them by \`amount\`. For Kajabi, \`product_id\` is the offer id and a name match reads the offer's title; Kajabi has no \`price_id\`. Prefer a GoHighLevel or Shopify name match on the product's name when one price is shared by two offers, as a $497 bump and a $497 guest ticket can be. An offer nobody pays for, such as a free webinar, may have no \`matches\`.
751
+ 1. **Offers**, front end first, numbered by \`stack_position\` from 1. Each has an \`offer_key\` (lowercase, digits and underscores), \`name\`, \`tier\` (\`front_end\` or \`back_end\`), \`delivery\` (course, event, coaching, service, product, membership, webinar, lead_magnet or other), \`price\`, \`currency\`, an optional \`price_note\` (plain words on the price: other prices, payment plans, doubts), and \`matches\`: how a sale is recognised. A match is \`{"platform": ..., "match_type": ..., "value": "..."}\`: platform \`stripe\`, \`ghl\`, \`shopify\` or \`kajabi\`, and match_type \`product_id\`, \`price_id\`, \`amount\` (whole units, with a \`currency\`), \`name_contains\` (any part of the product's name, ignoring case) or \`name_starts_with\` (the start of the product's name, ignoring case). Use \`name_starts_with\` for a family of products whose names begin with one code, such as event tickets named "OCA Dallas" and "OCA Austin"; \`name_contains\` "OCA" would also catch "Local Coaching". Stripe charges carry no product, so match them by \`amount\`. For Kajabi, \`product_id\` is the offer id and a name match reads the offer's title; Kajabi has no \`price_id\`. Prefer a GoHighLevel or Shopify name match on the product's name when one price is shared by two offers, as a $497 bump and a $497 guest ticket can be. No payment counts under two offers: an order line or payment two offers' matches both find counts under the one with the lower \`stack_position\`, and the save lists it as weak on both. Two lines of one order are two sales, one for each offer that finds its line. An offer nobody pays for, such as a free webinar, may have no \`matches\`.
752
752
  - **Price.** A free webinar's \`price\` is 0 when its page or GoHighLevel says it is free. When GoHighLevel or Kajabi and Stripe both show a sale, \`price\` is the GoHighLevel or Kajabi price that names the product, \`price_note\` gives the Stripe amount, and the offer matches both: a GoHighLevel name match or the Kajabi offer, and the Stripe \`amount\`. A sale Stripe holds counts only under Stripe. Any other price you cannot find in the evidence is \`null\`, with a \`price_note\` saying why.
753
753
  2. **Funnels and steps.** A funnel has a \`funnel_key\`, \`name\`, \`kind\` (\`front_end\` or \`back_end\`), \`entry_offer_key\`, \`window_days\` (how many days after one step a sale still counts for the next; 365 when left out) and \`steps\`. A step has a \`step_key\`, \`position\` (1, 2, 3 in the order people walk them), \`step_type\`, \`name\`, and optionally the \`offer_key\` it sells, a \`wait_note\` (plain words on the wait before it, such as "the months after the course"), \`assets\` and \`rules\`.
754
754
  - Step types: \`ad\`, \`landing_page\`, \`vsl\`, \`optin\`, \`application\`, \`booking\`, \`confirmation\`, \`sales_call\`, \`checkout\`, \`order_bump\`, \`upsell\`, \`downsell\`, \`thank_you\`, \`webinar_registration\`, \`webinar\`, \`event\`, \`email_nurture\`, \`sms_nurture\`, \`onboarding\`, \`other\`.
@@ -1767,7 +1767,7 @@ When the owner is in the session with you, ask about these 3 gaps, one at a time
1767
1767
 
1768
1768
  ${step(W.winners)}
1769
1769
 
1770
- ${W.winners.purpose[0].toUpperCase()}${W.winners.purpose.slice(1)}. Each ad has its \`numbers\`, \`pickedBecause\`, \`tags\` and Adzara \`test\`; each section's \`tests\` adds up each Adzara test. Leave out \`--week\` for the latest complete week. To check more often, swap \`--week\` for \`--days <n>\`, the last n complete days ending yesterday, or \`--from <YYYY-MM-DD> --to <YYYY-MM-DD>\` for any dates (the MCP tool takes \`week\`, \`days\` or \`from\` and \`to\`). The "sales" fields (\`numbers.sales\`, \`costPerSale\`, \`accountCostPerSale\`, \`adSales\`, \`judgedOn: "sales"\`) hold the client's goal, named in \`goal\`, and the "signups" fields the fallback goal, named in \`fallbackGoal\`. A short window holds few of the goal, so fewer ads can be judged on it and more are judged on the fallback; the report says so in \`notes\`.
1770
+ ${W.winners.purpose[0].toUpperCase()}${W.winners.purpose.slice(1)}. Each ad has its \`numbers\`, \`pickedBecause\`, \`tags\` and Adzara \`test\`; each section's \`tests\` adds up each Adzara test. Leave out \`--week\` for the latest complete week. To check more often, swap \`--week\` for \`--days <n>\`, the last n days ending today, or \`--from <YYYY-MM-DD> --to <YYYY-MM-DD>\` for any dates (the MCP tool takes \`week\`, \`days\` or \`from\` and \`to\`). The "sales" fields (\`numbers.sales\`, \`costPerSale\`, \`accountCostPerSale\`, \`adSales\`, \`judgedOn: "sales"\`) hold the client's goal, named in \`goal\`, and the "signups" fields the fallback goal, named in \`fallbackGoal\`. A short window holds few of the goal, so fewer ads can be judged on it and more are judged on the fallback; the report says so in \`notes\`.
1771
1771
 
1772
1772
  ## 3. Lessons
1773
1773
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrangled/cli",
3
- "version": "0.3.154",
3
+ "version": "0.3.156",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {