@aglyn/shared-ui-email-campaigns 1.0.0-beta.162 → 1.0.0-beta.164

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/shared-ui-email-campaigns",
3
- "version": "1.0.0-beta.162",
3
+ "version": "1.0.0-beta.164",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -29,8 +29,8 @@
29
29
  "./package.json": "./package.json"
30
30
  },
31
31
  "dependencies": {
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.162",
33
- "@aglyn/shared-util-email": "1.0.0-beta.162",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.164",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.164",
34
34
  "@swc/helpers": "0.5.23"
35
35
  },
36
36
  "peerDependencies": {
@@ -79,8 +79,15 @@ import type { CampaignCaveat } from './campaign-report';
79
79
  export declare const CAMPAIGN_CONVERSION_KINDS: readonly ["form", "lead", "contact", "booking"];
80
80
  /** One identify moment. Mirrors the writer's `CampaignConversionKind`. */
81
81
  export type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number];
82
- /** Which channel the credited touch arrived through. */
83
- export type CampaignTouchChannel = 'email' | 'web';
82
+ /**
83
+ * Which channel the credited touch arrived through.
84
+ *
85
+ * `sequence` (AGL-3254) is a one-to-one email a rep's sequence sent: the
86
+ * touch names the CAMPAIGN the sequence is in, like an email touch names
87
+ * the campaign whose mail was clicked, plus the sequence and the enrollment
88
+ * it came through, so a record can say which rep's outreach it credits.
89
+ */
90
+ export type CampaignTouchChannel = 'email' | 'web' | 'sequence';
84
91
  /**
85
92
  * What a reader calls one kind, and what the count means.
86
93
  *
@@ -121,8 +128,15 @@ export interface CampaignConversionRecord {
121
128
  /** The submission, lead, contact or booking this credits. */
122
129
  refId?: string;
123
130
  channel?: CampaignTouchChannel;
124
- /** The campaign document, when the touch was a click on our own mail. */
131
+ /**
132
+ * The campaign document, when the touch was a click on our own mail — or,
133
+ * for a `sequence` touch, the campaign container the sequence is in.
134
+ */
125
135
  campaignId?: string;
136
+ /** The sequence, when the touch was a sequence email (AGL-3254). */
137
+ sequenceId?: string;
138
+ /** The enrollment the sequence email went out under (AGL-3254). */
139
+ enrollmentId?: string;
126
140
  /** `utm_source`, when the touch was a link on the web. */
127
141
  source?: string;
128
142
  /** `utm_medium`, when the touch was a link on the web. */
@@ -274,9 +288,10 @@ export declare function campaignConversionsCoverage(options: {
274
288
  /**
275
289
  * How a record names the thing it was credited to.
276
290
  *
277
- * The email channel names a campaign document, so the screen can link to it.
278
- * The web channel names a label the marketer typed, which is text and never a
279
- * link — there is nothing at the other end of it.
291
+ * The email channel names a campaign document, so the screen can link to it,
292
+ * and so does a sequence touch: the campaign the sequence is in is a
293
+ * container with a page. The web channel names a label the marketer typed,
294
+ * which is text and never a link — there is nothing at the other end of it.
280
295
  *
281
296
  * The `utm_` triple is joined in the order a marketer set it, with the parts
282
297
  * that are absent left out rather than filled with a placeholder.
@@ -226,18 +226,21 @@ export const CAMPAIGN_CONVERSION_KIND_COPY = {
226
226
  /**
227
227
  * How a record names the thing it was credited to.
228
228
  *
229
- * The email channel names a campaign document, so the screen can link to it.
230
- * The web channel names a label the marketer typed, which is text and never a
231
- * link — there is nothing at the other end of it.
229
+ * The email channel names a campaign document, so the screen can link to it,
230
+ * and so does a sequence touch: the campaign the sequence is in is a
231
+ * container with a page. The web channel names a label the marketer typed,
232
+ * which is text and never a link — there is nothing at the other end of it.
232
233
  *
233
234
  * The `utm_` triple is joined in the order a marketer set it, with the parts
234
235
  * that are absent left out rather than filled with a placeholder.
235
236
  * `google / cpc` and `google / cpc / (none)` describe the same link, and only
236
237
  * one of them invites the reader to look for a campaign called "(none)".
237
238
  */ export function campaignTouchLabel(record) {
238
- var _record_campaignId;
239
239
  if (!record) return '';
240
- if (record.channel === 'email') return String((_record_campaignId = record.campaignId) != null ? _record_campaignId : '');
240
+ if (record.channel === 'email' || record.channel === 'sequence') {
241
+ var _record_campaignId;
242
+ return String((_record_campaignId = record.campaignId) != null ? _record_campaignId : '');
243
+ }
241
244
  const parts = [
242
245
  record.source,
243
246
  record.medium,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-conversions.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The window and the model name come from `@aglyn/shared-util-email`, for the\n * reason `campaign-revenue.ts` gives: the writer is in `tenant-data-admin`,\n * which may not import a feature plugin, and a rule defined on both sides of\n * the join drifts into a figure credited under one and printed under another.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n} from '@aglyn/shared-util-email'\nimport type { CampaignCaveat } from './campaign-report'\n\n/**\n * WHAT A CAMPAIGN CAUSED — the read half of the identify-moment join.\n *\n * `campaign-revenue.ts` answers what a campaign EARNED, from orders that name\n * their buyer. This answers what it CAUSED among people who were anonymous\n * until the moment they became somebody: they arrive from a campaign link,\n * browse, and only become identifiable when they submit a form, sign up, book\n * or check out. Same window, same last-click rule, same `model`/`windowDays`\n * stamped on every record, so the two figures on one screen are two readings\n * of one rule rather than two rules that happen to agree.\n *\n * ## THE KINDS ARE NEVER SUMMED, and this module makes that structural\n *\n * One form submission by a new person writes a submission record, a contact\n * and a lead — three true statements about one visitor action. Adding them\n * would treble every campaign's conversions, and the reader would have no way\n * to see it had happened, because the sum looks like a bigger version of a\n * real number.\n *\n * So {@link CampaignConversionsReport} carries no total, no `all`, no\n * `conversions` scalar, and there is no function here that reduces the kinds.\n * A screen that wanted one would have to write the addition itself. The\n * report also raises {@link CampaignCaveat} `conversions-kinds-overlap` so\n * the reader is told WHY the four figures stand apart, rather than being left\n * to assume the product forgot to add them up.\n *\n * ## THE WEB CHANNEL HAS NO ROLLUP, and cannot be given one\n *\n * A conversion is credited to one of two touches. An EMAIL touch names a\n * campaign document — a real entity with a real id — so its conversions roll\n * up under it beside the revenue the same join credits it with. A WEB touch\n * is a `utm_` label a marketer typed into a URL: no document, no id, and no\n * bound on how many distinct values exist, because anybody who can vary a\n * query string can mint another one.\n *\n * A rollup keyed on that label is a map a stranger can grow, which is the\n * same unbounded key space the analytics collector caps its per-day label map\n * against. So the writer increments the rollup for the email channel ONLY,\n * this reader states that where the figure is drawn, and web-channel records\n * are read as records — see {@link campaignConversionsCoverage}.\n *\n * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL\n *\n * Direct traffic writes no record. There is deliberately no\n * `utm_source=direct` placeholder, no referrer inference and no \"most recent\n * campaign on this site\" fallback: a conversion nobody can be credited with\n * is a conversion nobody is credited with.\n *\n * That makes the absence of a record load-bearing, and it makes a screen\n * showing only attributed conversions a lie by omission — it renders \"we\n * credited three of these\" as \"three of these happened\".\n * {@link campaignConversionsCoverage} exists so the unattributed count is a\n * figure on the page rather than an inference nobody makes.\n */\n\n/*\n * The constants are NOT re-exported here, though `campaign-revenue.ts`\n * re-exports the same two. Both modules sit behind one barrel, and a name\n * exported twice through it is a name a bundler has to disambiguate. Every\n * screen reads the rule off `model`/`windowDays` on the report anyway, which\n * is the stored value rather than today's constant — a campaign credited\n * under an older window has to print the window it was credited under.\n */\n\n/**\n * Which identify moment a record credits.\n *\n * An array first, because the ORDER is the reading order on every screen and\n * a second list of these is a second chance to leave one out. The union is\n * derived from it rather than written twice.\n */\nexport const CAMPAIGN_CONVERSION_KINDS = [\n 'form',\n 'lead',\n 'contact',\n 'booking',\n] as const\n\n/** One identify moment. Mirrors the writer's `CampaignConversionKind`. */\nexport type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number]\n\n/** Which channel the credited touch arrived through. */\nexport type CampaignTouchChannel = 'email' | 'web'\n\n/**\n * What a reader calls one kind, and what the count means.\n *\n * The note is the half that stops the addition. Each one names a DIFFERENT\n * population of the same visitors, and saying so under every figure is what\n * makes four numbers standing apart read as deliberate rather than as a\n * missing total.\n */\nexport interface CampaignConversionKindCopy {\n label: string\n note: string\n}\n\nexport const CAMPAIGN_CONVERSION_KIND_COPY: Readonly<\n Record<CampaignConversionKind, CampaignConversionKindCopy>\n> = {\n form: {\n label: 'Form submissions',\n note: 'submissions credited to this campaign',\n },\n lead: { label: 'Leads', note: 'new leads credited to this campaign' },\n contact: {\n label: 'Contacts',\n note: 'new contacts credited to this campaign',\n },\n booking: { label: 'Bookings', note: 'bookings credited to this campaign' },\n}\n\n/**\n * The stored shape of `campaigns/{campaignId}/reports/conversions`.\n *\n * Its own document beside `reports/revenue`, for the reason that one is: the\n * campaign document is read by the history list, the glance widget and the\n * send path, and a map that grows with what the campaign caused would enlarge\n * every one of those reads.\n */\nexport interface CampaignConversionsRollup {\n byKind?: Partial<Record<CampaignConversionKind, number>>\n /** The model these conversions were credited under. */\n model?: string\n /** The window, in days, they were credited inside. */\n windowDays?: number\n}\n\n/**\n * The stored shape of `hosts/{hostId}/campaignAttributions/{kind}:{refId}`.\n *\n * Read-side only, and every field optional: this is a document somebody\n * else's writer produced, and a reader that assumed a field was present would\n * throw on the first record written by an older version of it.\n */\nexport interface CampaignConversionRecord {\n kind?: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId?: string\n channel?: CampaignTouchChannel\n /** The campaign document, when the touch was a click on our own mail. */\n campaignId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs?: number\n /** When the visitor became identifiable, epoch ms. */\n convertedAtMs?: number\n model?: string\n windowDays?: number\n}\n\n/**\n * The document id for one conversion — `{kind}:{refId}`.\n *\n * The reader's half of the writer's `campaignConversionId`. It is restated\n * here rather than imported because the writer lives in `tenant-data-admin`,\n * which a UI library may not import, and it is one line whose shape is\n * asserted in this module's spec.\n *\n * Answers `null` for an unusable pair rather than building `form:undefined`,\n * which would be a valid document path pointing at a record that can never\n * exist — a keyed read that silently reports \"not attributed\" for every\n * record on the screen.\n */\nexport function campaignConversionId(\n kind: string | null | undefined,\n refId: string | null | undefined,\n): string | null {\n const k = String(kind ?? '')\n const ref = String(refId ?? '')\n if (!k || !ref) return null\n if (!(CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(k)) return null\n // A slash would leave the collection; the id scheme already spends the\n // colon, and a ref carrying one would make the pair ambiguous.\n if (ref.includes('/') || ref.includes(':')) return null\n return `${k}:${ref}`\n}\n\n/** A stored count as a non-negative integer. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw ?? 0))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/** One kind's figure on screen. Independent of every other kind's. */\nexport interface CampaignConversionKindReport {\n kind: CampaignConversionKind\n label: string\n /**\n * Conversions of this kind, or `null` when the rollup holds no entry.\n *\n * `null` rather than 0 for {@link CampaignConversionsReport.recorded}'s\n * reason, applied per kind: a site with no booking form has never written a\n * booking conversion, and rendering that as a measured zero invites the\n * reader to conclude the campaign failed at something it never attempted.\n */\n value: number | null\n note: string\n}\n\n/** Everything the conversions section renders. */\nexport interface CampaignConversionsReport {\n /**\n * One entry per kind, always all four and always in\n * {@link CAMPAIGN_CONVERSION_KINDS} order.\n *\n * There is deliberately NO total beside this array, and no field anywhere\n * on this report that holds one. See the module docblock.\n */\n kinds: CampaignConversionKindReport[]\n /**\n * Whether the rollup document exists at all.\n *\n * `false` is NOT \"this campaign caused nothing\" — it is also every campaign\n * sent before the join existed, and every campaign whose conversions all\n * arrived through the web channel, which writes no rollup. The screen\n * renders the difference rather than printing a zero for all three.\n */\n recorded: boolean\n /** At least one kind holds a figure. */\n any: boolean\n /** The model these figures were credited under, as stored. */\n model: string\n /** The window they were credited inside, as stored. */\n windowDays: number\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns the stored rollup into the conversions section.\n *\n * Takes the rollup and nothing else. Unlike the revenue report there is no\n * denominator to hand in: a conversion RATE over delivered messages would be\n * the same defect the revenue section refuses for orders — one visitor can\n * submit two forms, so the quotient passes 100% without anything being wrong\n * — and counting distinct people would need a document per person per\n * campaign, the per-recipient read this whole surface exists to refuse.\n */\nexport function campaignConversionsReport(options: {\n rollup: CampaignConversionsRollup | undefined\n}): CampaignConversionsReport {\n const { rollup } = options\n const stored = rollup?.byKind ?? {}\n const caveats: CampaignCaveat[] = []\n\n const kinds: CampaignConversionKindReport[] = CAMPAIGN_CONVERSION_KINDS.map(\n (kind) => {\n const raw = stored[kind]\n const copy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n return {\n kind,\n label: copy.label,\n // `undefined` means the rollup has no entry for this kind, which is\n // not a measured zero. A stored 0 is impossible — the writer only\n // ever increments — but it is read as unrecorded for the same reason.\n value: raw === undefined || count(raw) === 0 ? null : count(raw),\n note: copy.note,\n }\n },\n )\n\n const any = kinds.some((entry) => entry.value !== null)\n\n if (any) {\n caveats.push({\n id: 'conversions-kinds-overlap',\n message:\n 'These figures count different things about the same visits and are ' +\n 'deliberately not added together. One person filling in one form can ' +\n 'appear as a submission, a contact and a lead, so a total would ' +\n 'count that visit three times.',\n })\n caveats.push({\n id: 'conversions-web-not-rolled-up',\n message:\n 'Campaign emails only. A conversion credited to a link tagged with ' +\n 'utm_ parameters is recorded against that label rather than against ' +\n 'a campaign, so it is not in the figures above — those conversions ' +\n 'are listed under Conversions in the marketing console.',\n })\n }\n\n return {\n kinds,\n recorded: rollup !== undefined,\n any,\n model: String(rollup?.model ?? EMAIL_ATTRIBUTION_MODEL),\n windowDays: count(rollup?.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,\n caveats,\n }\n}\n\n/**\n * How much of one kind was credited to anything at all.\n *\n * ## Why this figure has to exist\n *\n * The join writes nothing for a conversion it cannot credit, so a list of\n * attribution records is a list of the SUCCESSES. Rendering only that turns\n * \"we credited four of these\" into \"four of these happened\", and the reader\n * draws a conclusion about their campaigns from a number that is mostly a\n * fact about how many visitors arrived without a campaign link.\n *\n * So the screen shows both halves and this computes the second. `attributed`\n * is counted over the attribution records; `total` over the records the\n * conversions themselves live in.\n *\n * ## Why the difference is a CEILING and says so\n *\n * The two counts come from different collections with different histories,\n * and the gap between them holds three things that are not the same:\n *\n * - conversions by visitors who arrived directly, which is what the figure\n * is meant to describe;\n * - conversions from before the join existed, which were never eligible; and\n * - for contacts, conversions on the org's OTHER sites, because contacts are\n * shared across an org while attributions are per host.\n *\n * None of those can be separated from the others without a field nobody\n * writes, so `unattributed` is reported as an upper bound and\n * {@link CampaignConversionsCoverage.exact} is `false` whenever the reasons\n * apply. A figure presented as exact when it is not is the failure this\n * whole surface is built to avoid.\n */\nexport interface CampaignConversionsCoverage {\n kind: CampaignConversionKind\n /** Conversions of this kind credited to some campaign or label. */\n attributed: number\n /** Conversions of this kind that exist, credited or not. */\n total: number\n /** `total - attributed`, clamped at zero. An upper bound, not a count. */\n unattributed: number\n /**\n * Whether `unattributed` may be read as \"arrived directly\".\n *\n * Always `false` today — the three reasons in the docblock all apply to\n * every host — and kept as a field rather than hardcoded in the copy so a\n * screen asks the model rather than restating its conclusion.\n */\n exact: boolean\n caveats: CampaignCaveat[]\n}\n\n/**\n * The attributed/unattributed split for one kind.\n *\n * `total` is `null` when it could not be counted — the aggregation failed, or\n * the collection is not readable from this surface — and the split is then\n * withheld entirely rather than defaulting `total` to `attributed`, which\n * would render every conversion as attributed and is the single most\n * flattering wrong answer available here.\n */\nexport function campaignConversionsCoverage(options: {\n kind: CampaignConversionKind\n attributed: number | null | undefined\n total: number | null | undefined\n /** The kind's records live outside this host, so `total` over-counts. */\n crossHostTotal?: boolean\n}): CampaignConversionsCoverage | null {\n const { kind, crossHostTotal } = options\n if (options.total == null || options.attributed == null) return null\n const attributed = count(options.attributed)\n const total = count(options.total)\n const caveats: CampaignCaveat[] = []\n\n /*\n * CLAMPED, and only here at the point of display. The two counts are taken\n * from two collections a moment apart, so a conversion recorded between\n * them makes `attributed` briefly exceed `total`; a negative count of\n * things that did not happen is not a sentence anybody can act on.\n */\n const unattributed = Math.max(0, total - attributed)\n\n caveats.push({\n id: 'conversions-unattributed-is-a-ceiling',\n message:\n `${unattributed.toLocaleString()} of these are not credited to any ` +\n 'campaign. Most arrived without following a campaign link and are ' +\n 'recorded as direct — nothing is guessed from a referrer, and no ' +\n 'campaign is credited for being the most recent one to run. The rest ' +\n 'are records from before campaign attribution was recorded at all, so ' +\n 'read this as an upper bound rather than a count of direct arrivals.',\n })\n if (crossHostTotal) {\n caveats.push({\n id: 'conversions-total-crosses-hosts',\n message:\n 'Contacts are shared across every site in this organization, while ' +\n 'attributions belong to one site. The uncredited figure therefore ' +\n 'includes contacts that were created on another site and could ' +\n 'never have been credited here.',\n })\n }\n\n return {\n kind,\n attributed,\n total,\n unattributed,\n // Never true while the reasons above stand. The field exists so a screen\n // reads the model rather than restating its conclusion in JSX.\n exact: false,\n caveats,\n }\n}\n\n/**\n * How a record names the thing it was credited to.\n *\n * The email channel names a campaign document, so the screen can link to it.\n * The web channel names a label the marketer typed, which is text and never a\n * link — there is nothing at the other end of it.\n *\n * The `utm_` triple is joined in the order a marketer set it, with the parts\n * that are absent left out rather than filled with a placeholder.\n * `google / cpc` and `google / cpc / (none)` describe the same link, and only\n * one of them invites the reader to look for a campaign called \"(none)\".\n */\nexport function campaignTouchLabel(\n record: CampaignConversionRecord | null | undefined,\n): string {\n if (!record) return ''\n if (record.channel === 'email') return String(record.campaignId ?? '')\n const parts = [record.source, record.medium, record.campaign]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n return parts.join(' / ')\n}\n"],"names":["EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","form","label","note","lead","contact","booking","campaignConversionId","kind","refId","k","String","ref","includes","count","raw","value","Math","floor","Number","isFinite","campaignConversionsReport","options","rollup","stored","byKind","caveats","kinds","map","copy","undefined","any","some","entry","push","id","message","recorded","model","windowDays","campaignConversionsCoverage","crossHostTotal","total","attributed","unattributed","max","toLocaleString","exact","campaignTouchLabel","record","channel","campaignId","parts","source","medium","campaign","part","trim","filter","Boolean","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-conversions.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The window and the model name come from `@aglyn/shared-util-email`, for the\n * reason `campaign-revenue.ts` gives: the writer is in `tenant-data-admin`,\n * which may not import a feature plugin, and a rule defined on both sides of\n * the join drifts into a figure credited under one and printed under another.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n} from '@aglyn/shared-util-email'\nimport type { CampaignCaveat } from './campaign-report'\n\n/**\n * WHAT A CAMPAIGN CAUSED — the read half of the identify-moment join.\n *\n * `campaign-revenue.ts` answers what a campaign EARNED, from orders that name\n * their buyer. This answers what it CAUSED among people who were anonymous\n * until the moment they became somebody: they arrive from a campaign link,\n * browse, and only become identifiable when they submit a form, sign up, book\n * or check out. Same window, same last-click rule, same `model`/`windowDays`\n * stamped on every record, so the two figures on one screen are two readings\n * of one rule rather than two rules that happen to agree.\n *\n * ## THE KINDS ARE NEVER SUMMED, and this module makes that structural\n *\n * One form submission by a new person writes a submission record, a contact\n * and a lead — three true statements about one visitor action. Adding them\n * would treble every campaign's conversions, and the reader would have no way\n * to see it had happened, because the sum looks like a bigger version of a\n * real number.\n *\n * So {@link CampaignConversionsReport} carries no total, no `all`, no\n * `conversions` scalar, and there is no function here that reduces the kinds.\n * A screen that wanted one would have to write the addition itself. The\n * report also raises {@link CampaignCaveat} `conversions-kinds-overlap` so\n * the reader is told WHY the four figures stand apart, rather than being left\n * to assume the product forgot to add them up.\n *\n * ## THE WEB CHANNEL HAS NO ROLLUP, and cannot be given one\n *\n * A conversion is credited to one of two touches. An EMAIL touch names a\n * campaign document — a real entity with a real id — so its conversions roll\n * up under it beside the revenue the same join credits it with. A WEB touch\n * is a `utm_` label a marketer typed into a URL: no document, no id, and no\n * bound on how many distinct values exist, because anybody who can vary a\n * query string can mint another one.\n *\n * A rollup keyed on that label is a map a stranger can grow, which is the\n * same unbounded key space the analytics collector caps its per-day label map\n * against. So the writer increments the rollup for the email channel ONLY,\n * this reader states that where the figure is drawn, and web-channel records\n * are read as records — see {@link campaignConversionsCoverage}.\n *\n * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL\n *\n * Direct traffic writes no record. There is deliberately no\n * `utm_source=direct` placeholder, no referrer inference and no \"most recent\n * campaign on this site\" fallback: a conversion nobody can be credited with\n * is a conversion nobody is credited with.\n *\n * That makes the absence of a record load-bearing, and it makes a screen\n * showing only attributed conversions a lie by omission — it renders \"we\n * credited three of these\" as \"three of these happened\".\n * {@link campaignConversionsCoverage} exists so the unattributed count is a\n * figure on the page rather than an inference nobody makes.\n */\n\n/*\n * The constants are NOT re-exported here, though `campaign-revenue.ts`\n * re-exports the same two. Both modules sit behind one barrel, and a name\n * exported twice through it is a name a bundler has to disambiguate. Every\n * screen reads the rule off `model`/`windowDays` on the report anyway, which\n * is the stored value rather than today's constant — a campaign credited\n * under an older window has to print the window it was credited under.\n */\n\n/**\n * Which identify moment a record credits.\n *\n * An array first, because the ORDER is the reading order on every screen and\n * a second list of these is a second chance to leave one out. The union is\n * derived from it rather than written twice.\n */\nexport const CAMPAIGN_CONVERSION_KINDS = [\n 'form',\n 'lead',\n 'contact',\n 'booking',\n] as const\n\n/** One identify moment. Mirrors the writer's `CampaignConversionKind`. */\nexport type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number]\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a one-to-one email a rep's sequence sent: the\n * touch names the CAMPAIGN the sequence is in, like an email touch names\n * the campaign whose mail was clicked, plus the sequence and the enrollment\n * it came through, so a record can say which rep's outreach it credits.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/**\n * What a reader calls one kind, and what the count means.\n *\n * The note is the half that stops the addition. Each one names a DIFFERENT\n * population of the same visitors, and saying so under every figure is what\n * makes four numbers standing apart read as deliberate rather than as a\n * missing total.\n */\nexport interface CampaignConversionKindCopy {\n label: string\n note: string\n}\n\nexport const CAMPAIGN_CONVERSION_KIND_COPY: Readonly<\n Record<CampaignConversionKind, CampaignConversionKindCopy>\n> = {\n form: {\n label: 'Form submissions',\n note: 'submissions credited to this campaign',\n },\n lead: { label: 'Leads', note: 'new leads credited to this campaign' },\n contact: {\n label: 'Contacts',\n note: 'new contacts credited to this campaign',\n },\n booking: { label: 'Bookings', note: 'bookings credited to this campaign' },\n}\n\n/**\n * The stored shape of `campaigns/{campaignId}/reports/conversions`.\n *\n * Its own document beside `reports/revenue`, for the reason that one is: the\n * campaign document is read by the history list, the glance widget and the\n * send path, and a map that grows with what the campaign caused would enlarge\n * every one of those reads.\n */\nexport interface CampaignConversionsRollup {\n byKind?: Partial<Record<CampaignConversionKind, number>>\n /** The model these conversions were credited under. */\n model?: string\n /** The window, in days, they were credited inside. */\n windowDays?: number\n}\n\n/**\n * The stored shape of `hosts/{hostId}/campaignAttributions/{kind}:{refId}`.\n *\n * Read-side only, and every field optional: this is a document somebody\n * else's writer produced, and a reader that assumed a field was present would\n * throw on the first record written by an older version of it.\n */\nexport interface CampaignConversionRecord {\n kind?: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId?: string\n channel?: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or,\n * for a `sequence` touch, the campaign container the sequence is in.\n */\n campaignId?: string\n /** The sequence, when the touch was a sequence email (AGL-3254). */\n sequenceId?: string\n /** The enrollment the sequence email went out under (AGL-3254). */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs?: number\n /** When the visitor became identifiable, epoch ms. */\n convertedAtMs?: number\n model?: string\n windowDays?: number\n}\n\n/**\n * The document id for one conversion — `{kind}:{refId}`.\n *\n * The reader's half of the writer's `campaignConversionId`. It is restated\n * here rather than imported because the writer lives in `tenant-data-admin`,\n * which a UI library may not import, and it is one line whose shape is\n * asserted in this module's spec.\n *\n * Answers `null` for an unusable pair rather than building `form:undefined`,\n * which would be a valid document path pointing at a record that can never\n * exist — a keyed read that silently reports \"not attributed\" for every\n * record on the screen.\n */\nexport function campaignConversionId(\n kind: string | null | undefined,\n refId: string | null | undefined,\n): string | null {\n const k = String(kind ?? '')\n const ref = String(refId ?? '')\n if (!k || !ref) return null\n if (!(CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(k)) return null\n // A slash would leave the collection; the id scheme already spends the\n // colon, and a ref carrying one would make the pair ambiguous.\n if (ref.includes('/') || ref.includes(':')) return null\n return `${k}:${ref}`\n}\n\n/** A stored count as a non-negative integer. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw ?? 0))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/** One kind's figure on screen. Independent of every other kind's. */\nexport interface CampaignConversionKindReport {\n kind: CampaignConversionKind\n label: string\n /**\n * Conversions of this kind, or `null` when the rollup holds no entry.\n *\n * `null` rather than 0 for {@link CampaignConversionsReport.recorded}'s\n * reason, applied per kind: a site with no booking form has never written a\n * booking conversion, and rendering that as a measured zero invites the\n * reader to conclude the campaign failed at something it never attempted.\n */\n value: number | null\n note: string\n}\n\n/** Everything the conversions section renders. */\nexport interface CampaignConversionsReport {\n /**\n * One entry per kind, always all four and always in\n * {@link CAMPAIGN_CONVERSION_KINDS} order.\n *\n * There is deliberately NO total beside this array, and no field anywhere\n * on this report that holds one. See the module docblock.\n */\n kinds: CampaignConversionKindReport[]\n /**\n * Whether the rollup document exists at all.\n *\n * `false` is NOT \"this campaign caused nothing\" — it is also every campaign\n * sent before the join existed, and every campaign whose conversions all\n * arrived through the web channel, which writes no rollup. The screen\n * renders the difference rather than printing a zero for all three.\n */\n recorded: boolean\n /** At least one kind holds a figure. */\n any: boolean\n /** The model these figures were credited under, as stored. */\n model: string\n /** The window they were credited inside, as stored. */\n windowDays: number\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns the stored rollup into the conversions section.\n *\n * Takes the rollup and nothing else. Unlike the revenue report there is no\n * denominator to hand in: a conversion RATE over delivered messages would be\n * the same defect the revenue section refuses for orders — one visitor can\n * submit two forms, so the quotient passes 100% without anything being wrong\n * — and counting distinct people would need a document per person per\n * campaign, the per-recipient read this whole surface exists to refuse.\n */\nexport function campaignConversionsReport(options: {\n rollup: CampaignConversionsRollup | undefined\n}): CampaignConversionsReport {\n const { rollup } = options\n const stored = rollup?.byKind ?? {}\n const caveats: CampaignCaveat[] = []\n\n const kinds: CampaignConversionKindReport[] = CAMPAIGN_CONVERSION_KINDS.map(\n (kind) => {\n const raw = stored[kind]\n const copy = CAMPAIGN_CONVERSION_KIND_COPY[kind]\n return {\n kind,\n label: copy.label,\n // `undefined` means the rollup has no entry for this kind, which is\n // not a measured zero. A stored 0 is impossible — the writer only\n // ever increments — but it is read as unrecorded for the same reason.\n value: raw === undefined || count(raw) === 0 ? null : count(raw),\n note: copy.note,\n }\n },\n )\n\n const any = kinds.some((entry) => entry.value !== null)\n\n if (any) {\n caveats.push({\n id: 'conversions-kinds-overlap',\n message:\n 'These figures count different things about the same visits and are ' +\n 'deliberately not added together. One person filling in one form can ' +\n 'appear as a submission, a contact and a lead, so a total would ' +\n 'count that visit three times.',\n })\n caveats.push({\n id: 'conversions-web-not-rolled-up',\n message:\n 'Campaign emails only. A conversion credited to a link tagged with ' +\n 'utm_ parameters is recorded against that label rather than against ' +\n 'a campaign, so it is not in the figures above — those conversions ' +\n 'are listed under Conversions in the marketing console.',\n })\n }\n\n return {\n kinds,\n recorded: rollup !== undefined,\n any,\n model: String(rollup?.model ?? EMAIL_ATTRIBUTION_MODEL),\n windowDays: count(rollup?.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,\n caveats,\n }\n}\n\n/**\n * How much of one kind was credited to anything at all.\n *\n * ## Why this figure has to exist\n *\n * The join writes nothing for a conversion it cannot credit, so a list of\n * attribution records is a list of the SUCCESSES. Rendering only that turns\n * \"we credited four of these\" into \"four of these happened\", and the reader\n * draws a conclusion about their campaigns from a number that is mostly a\n * fact about how many visitors arrived without a campaign link.\n *\n * So the screen shows both halves and this computes the second. `attributed`\n * is counted over the attribution records; `total` over the records the\n * conversions themselves live in.\n *\n * ## Why the difference is a CEILING and says so\n *\n * The two counts come from different collections with different histories,\n * and the gap between them holds three things that are not the same:\n *\n * - conversions by visitors who arrived directly, which is what the figure\n * is meant to describe;\n * - conversions from before the join existed, which were never eligible; and\n * - for contacts, conversions on the org's OTHER sites, because contacts are\n * shared across an org while attributions are per host.\n *\n * None of those can be separated from the others without a field nobody\n * writes, so `unattributed` is reported as an upper bound and\n * {@link CampaignConversionsCoverage.exact} is `false` whenever the reasons\n * apply. A figure presented as exact when it is not is the failure this\n * whole surface is built to avoid.\n */\nexport interface CampaignConversionsCoverage {\n kind: CampaignConversionKind\n /** Conversions of this kind credited to some campaign or label. */\n attributed: number\n /** Conversions of this kind that exist, credited or not. */\n total: number\n /** `total - attributed`, clamped at zero. An upper bound, not a count. */\n unattributed: number\n /**\n * Whether `unattributed` may be read as \"arrived directly\".\n *\n * Always `false` today — the three reasons in the docblock all apply to\n * every host — and kept as a field rather than hardcoded in the copy so a\n * screen asks the model rather than restating its conclusion.\n */\n exact: boolean\n caveats: CampaignCaveat[]\n}\n\n/**\n * The attributed/unattributed split for one kind.\n *\n * `total` is `null` when it could not be counted — the aggregation failed, or\n * the collection is not readable from this surface — and the split is then\n * withheld entirely rather than defaulting `total` to `attributed`, which\n * would render every conversion as attributed and is the single most\n * flattering wrong answer available here.\n */\nexport function campaignConversionsCoverage(options: {\n kind: CampaignConversionKind\n attributed: number | null | undefined\n total: number | null | undefined\n /** The kind's records live outside this host, so `total` over-counts. */\n crossHostTotal?: boolean\n}): CampaignConversionsCoverage | null {\n const { kind, crossHostTotal } = options\n if (options.total == null || options.attributed == null) return null\n const attributed = count(options.attributed)\n const total = count(options.total)\n const caveats: CampaignCaveat[] = []\n\n /*\n * CLAMPED, and only here at the point of display. The two counts are taken\n * from two collections a moment apart, so a conversion recorded between\n * them makes `attributed` briefly exceed `total`; a negative count of\n * things that did not happen is not a sentence anybody can act on.\n */\n const unattributed = Math.max(0, total - attributed)\n\n caveats.push({\n id: 'conversions-unattributed-is-a-ceiling',\n message:\n `${unattributed.toLocaleString()} of these are not credited to any ` +\n 'campaign. Most arrived without following a campaign link and are ' +\n 'recorded as direct — nothing is guessed from a referrer, and no ' +\n 'campaign is credited for being the most recent one to run. The rest ' +\n 'are records from before campaign attribution was recorded at all, so ' +\n 'read this as an upper bound rather than a count of direct arrivals.',\n })\n if (crossHostTotal) {\n caveats.push({\n id: 'conversions-total-crosses-hosts',\n message:\n 'Contacts are shared across every site in this organization, while ' +\n 'attributions belong to one site. The uncredited figure therefore ' +\n 'includes contacts that were created on another site and could ' +\n 'never have been credited here.',\n })\n }\n\n return {\n kind,\n attributed,\n total,\n unattributed,\n // Never true while the reasons above stand. The field exists so a screen\n // reads the model rather than restating its conclusion in JSX.\n exact: false,\n caveats,\n }\n}\n\n/**\n * How a record names the thing it was credited to.\n *\n * The email channel names a campaign document, so the screen can link to it,\n * and so does a sequence touch: the campaign the sequence is in is a\n * container with a page. The web channel names a label the marketer typed,\n * which is text and never a link — there is nothing at the other end of it.\n *\n * The `utm_` triple is joined in the order a marketer set it, with the parts\n * that are absent left out rather than filled with a placeholder.\n * `google / cpc` and `google / cpc / (none)` describe the same link, and only\n * one of them invites the reader to look for a campaign called \"(none)\".\n */\nexport function campaignTouchLabel(\n record: CampaignConversionRecord | null | undefined,\n): string {\n if (!record) return ''\n if (record.channel === 'email' || record.channel === 'sequence') {\n return String(record.campaignId ?? '')\n }\n const parts = [record.source, record.medium, record.campaign]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n return parts.join(' / ')\n}\n"],"names":["EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","CAMPAIGN_CONVERSION_KINDS","CAMPAIGN_CONVERSION_KIND_COPY","form","label","note","lead","contact","booking","campaignConversionId","kind","refId","k","String","ref","includes","count","raw","value","Math","floor","Number","isFinite","campaignConversionsReport","options","rollup","stored","byKind","caveats","kinds","map","copy","undefined","any","some","entry","push","id","message","recorded","model","windowDays","campaignConversionsCoverage","crossHostTotal","total","attributed","unattributed","max","toLocaleString","exact","campaignTouchLabel","record","channel","campaignId","parts","source","medium","campaign","part","trim","filter","Boolean","join"],"mappings":"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"}
@@ -150,7 +150,7 @@ export declare function campaignRate(numerator: number | undefined, denominator:
150
150
  /** Why a number the report would otherwise show is being withheld. */
151
151
  export interface CampaignCaveat {
152
152
  /** Stable id, so a spec can assert on the caveat rather than its prose. */
153
- id: 'delivery-unrecorded' | 'click-tracking-unrecorded' | 'audience-truncated' | 'send-deferred' | 'revenue-denominator-unrecorded' | 'revenue-multi-currency' | 'revenue-mid-flight' | 'revenue-mixed-model' | 'conversions-kinds-overlap' | 'conversions-web-not-rolled-up' | 'conversions-unattributed-is-a-ceiling' | 'conversions-total-crosses-hosts';
153
+ id: 'delivery-unrecorded' | 'click-tracking-unrecorded' | 'audience-truncated' | 'send-deferred' | 'revenue-denominator-unrecorded' | 'revenue-multi-currency' | 'revenue-mid-flight' | 'revenue-mixed-model' | 'conversions-kinds-overlap' | 'conversions-web-not-rolled-up' | 'conversions-unattributed-is-a-ceiling' | 'conversions-total-crosses-hosts' | 'sequences-funnel-not-summed';
154
154
  message: string;
155
155
  }
156
156
  /** One population the send measured, for the audience breakdown. */
@@ -302,3 +302,49 @@ export interface CampaignLinkReport {
302
302
  * Both excluded figures are returned so the screen can state them.
303
303
  */
304
304
  export declare function campaignLinkReport(rollup: CampaignLinkRollup | undefined): CampaignLinkReport;
305
+ /**
306
+ * The outcomes the runtime credits, in funnel order. An array first, because
307
+ * the ORDER is the reading order and a second list is a second chance to
308
+ * leave one out; the union is derived from it.
309
+ */
310
+ export declare const CAMPAIGN_SEQUENCE_OUTCOMES: readonly ["enrolled", "sent", "replied", "meetings", "converted"];
311
+ export type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number];
312
+ /** The per-host collection holding one document per campaign. */
313
+ export declare const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = "campaignSequenceReports";
314
+ /** What a reader calls each outcome, and what the count means. */
315
+ export declare const CAMPAIGN_SEQUENCE_OUTCOME_COPY: Readonly<Record<CampaignSequenceOutcome, {
316
+ label: string;
317
+ note: string;
318
+ }>>;
319
+ /**
320
+ * The stored shape of `campaignSequenceReports/{campaignId}`.
321
+ *
322
+ * Read-side only, every field optional: another plugin's runtime wrote it,
323
+ * and a reader that assumed a field was present would throw on the first
324
+ * document written before the field existed.
325
+ */
326
+ export interface CampaignSequencesRollup {
327
+ byOutcome?: Partial<Record<CampaignSequenceOutcome, number>>;
328
+ /** When the runtime last credited anything, epoch ms. */
329
+ updatedAtMs?: number;
330
+ }
331
+ /** One outcome's figure on screen. */
332
+ export interface CampaignSequenceFigure {
333
+ outcome: CampaignSequenceOutcome;
334
+ label: string;
335
+ /** The count, or `null` when the rollup holds no entry for the outcome. */
336
+ value: number | null;
337
+ note: string;
338
+ }
339
+ /** Everything the Sequences section renders. */
340
+ export interface CampaignSequencesReport {
341
+ /** Always all five, always in {@link CAMPAIGN_SEQUENCE_OUTCOMES} order. */
342
+ figures: CampaignSequenceFigure[];
343
+ /** Whether the rollup document exists at all — see the block header. */
344
+ recorded: boolean;
345
+ /** At least one outcome holds a figure. */
346
+ any: boolean;
347
+ caveats: CampaignCaveat[];
348
+ }
349
+ /** Turns the stored rollup into the Sequences section. */
350
+ export declare function campaignSequencesReport(rollup: CampaignSequencesRollup | undefined): CampaignSequencesReport;
@@ -322,5 +322,96 @@
322
322
  truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0
323
323
  };
324
324
  }
325
+ /*==========================================
326
+ * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).
327
+ *
328
+ * A sequence joins a campaign the way a form does — `campaignIds` on its own
329
+ * document — and its outcomes are then the campaign's to report: every
330
+ * person enrolled, the first email each one was sent, the replies, the
331
+ * meetings booked from a sequence link, and the enrolled leads that
332
+ * converted. The Outreach runtime credits each outcome once per enrollment
333
+ * to every campaign the sequence was in when the person was enrolled, into
334
+ * `hosts/{hostId}/campaignSequenceReports/{campaignId}` — one document per
335
+ * campaign, beside the conversions rollup and never inside the campaign
336
+ * document, which the history list and the glance widget read.
337
+ *
338
+ * ## The five figures are a funnel, not a sum
339
+ *
340
+ * Each figure counts ENROLLMENTS at a stage: 40 enrolled, 38 sent, 6
341
+ * replied, 2 meetings, 1 converted. They are read across, never added — a
342
+ * person is in every stage they reached — and the model keeps them as an
343
+ * ordered list rather than a total for the reason the conversion kinds are
344
+ * kept apart.
345
+ *
346
+ * ## Absent is not zero
347
+ *
348
+ * A campaign no sequence was ever in has no document, and a stage nobody
349
+ * reached has no field. Both are `null` here, so the screen can say
350
+ * "nothing recorded" for the first and draw a dash for the second, rather
351
+ * than printing a measured 0 replies for a campaign whose sequence went
352
+ * out yesterday.
353
+ *=========================================*/ /**
354
+ * The outcomes the runtime credits, in funnel order. An array first, because
355
+ * the ORDER is the reading order and a second list is a second chance to
356
+ * leave one out; the union is derived from it.
357
+ */ export const CAMPAIGN_SEQUENCE_OUTCOMES = [
358
+ 'enrolled',
359
+ 'sent',
360
+ 'replied',
361
+ 'meetings',
362
+ 'converted'
363
+ ];
364
+ /** The per-host collection holding one document per campaign. */ export const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports';
365
+ /** What a reader calls each outcome, and what the count means. */ export const CAMPAIGN_SEQUENCE_OUTCOME_COPY = {
366
+ enrolled: {
367
+ label: 'Enrolled',
368
+ note: 'people enrolled in a sequence in this campaign'
369
+ },
370
+ sent: {
371
+ label: 'Sent',
372
+ note: 'of them, sent their first sequence email'
373
+ },
374
+ replied: {
375
+ label: 'Replied',
376
+ note: 'of them, who wrote back'
377
+ },
378
+ meetings: {
379
+ label: 'Meetings',
380
+ note: 'bookings made from a sequence link'
381
+ },
382
+ converted: {
383
+ label: 'Converted',
384
+ note: 'enrolled leads that became contacts'
385
+ }
386
+ };
387
+ /** Turns the stored rollup into the Sequences section. */ export function campaignSequencesReport(rollup) {
388
+ var _ref;
389
+ const stored = (_ref = rollup == null ? void 0 : rollup.byOutcome) != null ? _ref : {};
390
+ const figures = CAMPAIGN_SEQUENCE_OUTCOMES.map((outcome)=>{
391
+ const raw = stored[outcome];
392
+ const value = Math.floor(Number(raw != null ? raw : 0));
393
+ return {
394
+ outcome,
395
+ label: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].label,
396
+ // Unrecorded, negative and non-numeric all read as "no figure": the
397
+ // runtime only ever increments, so a stored 0 is not a count either.
398
+ value: raw === undefined || !Number.isFinite(value) || value <= 0 ? null : value,
399
+ note: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].note
400
+ };
401
+ });
402
+ const any = figures.some((figure)=>figure.value !== null);
403
+ const caveats = any ? [
404
+ {
405
+ id: 'sequences-funnel-not-summed',
406
+ message: 'Each figure counts the people who reached that stage, so a person ' + 'who replied is counted under Enrolled and Sent as well. Read them ' + 'across; they are deliberately not added together.'
407
+ }
408
+ ] : [];
409
+ return {
410
+ figures,
411
+ recorded: rollup !== undefined,
412
+ any,
413
+ caveats
414
+ };
415
+ }
325
416
 
326
417
  //# sourceMappingURL=campaign-report.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-report.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * CAMPAIGN REPORTING MATH — the only place a rate is computed.\n *\n * ## Why a pure module and not a component\n *\n * Every number on the report screen is a division, and a division is where\n * email reporting goes wrong. Putting the arithmetic in JSX means the\n * denominator is chosen by whoever writes the next card, in a file nobody\n * tests for arithmetic; putting it here means each rate is named once,\n * carries its own denominator as data, and is provable.\n *\n * ## The rule this module exists to enforce\n *\n * **A rate is a triple — numerator, denominator, and the NAME of the\n * denominator — or it is not reported.** An open rate over `sent` and an open\n * rate over `delivered` are different numbers with the same label, and the\n * gap between them is exactly the mail that bounced. The industry convention\n * is over `delivered`, and a report that quietly used `sent` would read\n * higher than the same campaign measured anywhere else.\n *\n * So {@link CampaignRate} carries `denominatorLabel`, and the screen is\n * required to render it. There is no overload that omits it.\n *\n * ## Why some rates are deliberately absent\n *\n * {@link campaignRate} answers `null`, not zero, when it cannot divide:\n *\n * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open\n * rate, it is no open rate. Rendering 0% invites the reader to compare it\n * with a campaign that really did fail.\n * - **An UNKNOWN denominator.** `delivered` is counted by the delivery\n * webhook, which was connected after some campaigns were sent. A campaign\n * with 400 sends and no delivery events has an unknown denominator, not a\n * denominator of zero — and dividing by `sent` instead is precisely the\n * flattering substitution above.\n *\n * ## The structural-zero window\n *\n * Click tracking rewrites links in the HTML part. Sends that carried no HTML\n * part were therefore untrackable, and every one of those campaigns reports 0\n * clicks whatever the recipients actually did — a real 0 and a structural 0\n * rendered identically. `send-email.ts` now synthesises an HTML part for a\n * text-only send, so every send after that carries one, and\n * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.\n *\n * A campaign with no such marker predates the record. Its click COUNT is\n * still shown — it is a real count of real events — but no click RATE is\n * computed from it, because a rate presents the number as a measurement of\n * the audience and for those campaigns it is a measurement of the sender.\n */\n\n/**\n * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.\n *\n * Every field is optional and every reader defaults it, because these are\n * written by three different writers at three different times — the send, the\n * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,\n * readable document from the instant the first of them lands.\n */\nexport interface CampaignStats {\n /*========================================\n * WRITTEN BY THE SEND. Never recomputed.\n *\n * These are the truth of what happened, recorded once by the code that did\n * it. Re-deriving any of them at read time would produce a number that\n * disagrees with the send — the audience has moved on since, suppressions\n * have been added, consent has been recorded — and the recorded one is the\n * one that describes the campaign.\n *=======================================*/\n /** The whole audience the send was taken from, before the per-send cap. */\n audienceSize?: number\n /** `audienceSize` is a FLOOR: audience resolution hit its read ceiling. */\n audienceSizeTruncated?: boolean\n /** Addresses this send ADDRESSED — the audience after the per-send cap. */\n recipients?: number\n /** Messages the provider accepted. The `sent` in \"sent/recipients\". */\n sent?: number\n /** Of the audience, how many carry a recorded marketing consent basis. */\n consented?: number\n /** Of `consented`, how many hold a basis an operator asserted for them. */\n consentedByOperator?: number\n /** Of the audience, how many are reachable only because enforcement is\n * not retroactive — the population a strict consent policy removes. */\n grandfathered?: number\n /** Of the audience, how many the consent rule refused to mail. */\n consentWithheld?: number\n /** Of `recipients`, how many were already suppressed (unsubscribed,\n * bounced or complained on an earlier send). */\n suppressed?: number\n /** Of `recipients`, how many asked this site for mail less often than this\n * send would have arrived. Still subscribed; reached by a later campaign. */\n cadenceHeld?: number\n /** Recipients the hourly send governor refused mid-batch. */\n deferred?: number\n /**\n * This send carried an HTML part, so its links were trackable.\n *\n * Absent on every campaign sent before the field existed — see the\n * structural-zero note in this module's header. Absent is NOT false; it is\n * \"not recorded\", and the report says so rather than guessing.\n */\n clickTracked?: boolean\n /** Per-variant send counts for an A/B campaign. */\n variantSends?: Record<string, number>\n\n /*========================================\n * WRITTEN BY THE DELIVERY WEBHOOK, as increments.\n *=======================================*/\n /** Messages the receiving server accepted. The rate DENOMINATOR. */\n delivered?: number\n /** Open EVENTS. One reader opening four times counts four. */\n opens?: number\n /** Click EVENTS. One reader clicking three links counts three. */\n clicks?: number\n /** Messages whose FIRST open was seen — distinct readers who opened. */\n uniqueOpens?: number\n /** Messages whose FIRST click was seen — distinct readers who clicked. */\n uniqueClicks?: number\n /** Messages that bounced, permanent and transient together. */\n bounced?: number\n /** Recipients who pressed \"report spam\". */\n complained?: number\n\n /*========================================\n * WRITTEN BY THE UNSUBSCRIBE HANDLER.\n *=======================================*/\n /** Recipients who unsubscribed through THIS campaign's link. */\n unsubscribes?: number\n}\n\n/**\n * One rate, with the denominator it was taken over named as data.\n *\n * `denominatorLabel` is not a display nicety. It is the field that makes two\n * numbers called \"open rate\" distinguishable, and the screen renders it\n * beside the percentage for that reason.\n */\nexport interface CampaignRate {\n /** 0–1. Multiply for display; the model never formats. */\n value: number\n numerator: number\n denominator: number\n /** Reader-facing name of the denominator, e.g. `'delivered'`. */\n denominatorLabel: string\n}\n\n/**\n * A rate, or `null` when one cannot honestly be taken.\n *\n * `null` on a zero or unknown denominator — see the module header for why\n * that is not the same as 0%.\n */\nexport function campaignRate(\n numerator: number | undefined,\n denominator: number | undefined,\n denominatorLabel: string,\n): CampaignRate | null {\n const top = Number(numerator ?? 0)\n const bottom = Number(denominator ?? 0)\n if (!Number.isFinite(top) || !Number.isFinite(bottom)) return null\n if (bottom <= 0) return null\n return {\n value: top / bottom,\n numerator: top,\n denominator: bottom,\n denominatorLabel,\n }\n}\n\n/** Why a number the report would otherwise show is being withheld. */\nexport interface CampaignCaveat {\n /** Stable id, so a spec can assert on the caveat rather than its prose. */\n id:\n | 'delivery-unrecorded'\n | 'click-tracking-unrecorded'\n | 'audience-truncated'\n | 'send-deferred'\n /* Raised by `campaign-revenue.ts`, which reports through this shape so\n * the screen has one way of saying \"a number is being withheld and here\n * is why\" rather than one per section. */\n | 'revenue-denominator-unrecorded'\n | 'revenue-multi-currency'\n | 'revenue-mid-flight'\n | 'revenue-mixed-model'\n /* Raised by `campaign-conversions.ts`, through this shape for the same\n * reason: one way of saying \"a number is being withheld, or must not be\n * read the obvious way, and here is why\". The first two are the reasons\n * the four conversion kinds stand apart instead of totalling; the last\n * two qualify the uncredited figure rather than withholding it. */\n | 'conversions-kinds-overlap'\n | 'conversions-web-not-rolled-up'\n | 'conversions-unattributed-is-a-ceiling'\n | 'conversions-total-crosses-hosts'\n message: string\n}\n\n/** One population the send measured, for the audience breakdown. */\nexport interface CampaignPopulation {\n id: string\n label: string\n count: number\n /** What this count is a part OF, named. */\n ofLabel: string\n of: number\n}\n\n/** Everything the report screen renders, decided here rather than in JSX. */\nexport interface CampaignReport {\n sent: number\n recipients: number\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n /** Accepted by the receiving server, over what the provider accepted. */\n delivery: CampaignRate | null\n /** Distinct readers who opened, over delivered. */\n open: CampaignRate | null\n /** Distinct readers who clicked, over delivered. */\n click: CampaignRate | null\n /**\n * Distinct clickers over distinct OPENERS — a different question from\n * `click`, and the one the two get confused for. It answers \"of the\n * people who read it, how many acted\", not \"of the people who received\n * it\". Reported separately and labelled separately, never as \"click\n * rate\".\n */\n clickToOpen: CampaignRate | null\n /** Bounced over what the provider accepted. */\n bounce: CampaignRate | null\n /** Complaints over delivered — the number mailbox providers judge on. */\n complaint: CampaignRate | null\n /** Unsubscribes through this campaign's link, over delivered. */\n unsubscribe: CampaignRate | null\n }\n populations: CampaignPopulation[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns a stored `stats` map into the report.\n *\n * ## The denominator decisions, in one place\n *\n * - **`delivered`** carries the engagement rates — open, click, complaint,\n * unsubscribe. Mail that bounced was never in front of a human, so\n * including it in the denominator of an open rate depresses a number that\n * describes the audience with a fact about the address list. This is also\n * the convention every other tool reports, which matters: a merchant\n * comparing our figure with their previous ESP's must be comparing the same\n * quantity.\n * - **`sent`** carries the delivery and bounce rates, because those describe\n * what happened to what we handed the provider, and `delivered` is the\n * numerator of one of them — a rate cannot be over itself.\n * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS\n * over recipients can exceed 100% the moment one person opens twice, and a\n * percentage above 100 is how a reader learns the number means something\n * other than what it says. Both are shown; only the distinct count is\n * divided.\n *\n * ## `delivered` unknown vs. zero\n *\n * A campaign predating the delivery webhook records no `delivered` at all.\n * That is reported as `null` and every rate over it is withheld, with a\n * caveat naming the reason — rather than substituting `sent`, which would\n * silently publish the flattered number this module exists to refuse.\n */\nexport function campaignReport(stats: CampaignStats | undefined): CampaignReport {\n const source = stats ?? {}\n const sent = Number(source.sent ?? 0)\n const recipients = Number(source.recipients ?? 0)\n const opens = Number(source.opens ?? 0)\n const clicks = Number(source.clicks ?? 0)\n const bounced = Number(source.bounced ?? 0)\n const complained = Number(source.complained ?? 0)\n const unsubscribes = Number(source.unsubscribes ?? 0)\n\n /*\n * ABSENT, not zero. `stats.delivered` is written only by the delivery\n * webhook, so `undefined` means \"no delivery event has ever been recorded\n * for this campaign\" — which for an old campaign means the webhook was not\n * connected, and for a campaign sent thirty seconds ago means the events\n * are still in flight. Neither is \"nothing was delivered\", and both are\n * ruined by `?? 0`, which would turn the unknown into a hard zero and make\n * every rate over it `null` for the RIGHT answer by the WRONG reasoning —\n * and would render \"0 delivered\" on screen beside \"500 sent\".\n */\n const delivered =\n source.delivered === undefined ? null : Number(source.delivered)\n const uniqueOpens =\n source.uniqueOpens === undefined ? null : Number(source.uniqueOpens)\n const uniqueClicks =\n source.uniqueClicks === undefined ? null : Number(source.uniqueClicks)\n\n /*\n * The click rate is withheld for a campaign that never recorded carrying an\n * HTML part, even when clicks are non-zero and `delivered` is known. See\n * the structural-zero note in the module header: for those sends 0 is the\n * only value the number could ever have taken, so a rate computed from it\n * measures our sending code rather than the recipients.\n */\n const clickTrackable = source.clickTracked === true\n\n const rates: CampaignReport['rates'] = {\n /*\n * `null` when `delivered` is UNRECORDED, and this is the one rate where\n * the numerator can be unknown rather than zero.\n *\n * Everywhere else an absent numerator is a genuine nought — a campaign\n * with delivery events and no opens really does have a 0% open rate, and\n * that is worth showing. Here the numerator IS the unrecorded quantity,\n * so `campaignRate(undefined, 1000, 'sent')` would divide a missing\n * measurement by a real one and publish \"0.0% delivery rate — 0 of 1,000\n * sent\" for a campaign whose delivery events were merely never recorded.\n * That is the flattering-substitution failure this module exists to\n * refuse, running in the other direction: not a rate that reads too high,\n * but a campaign that reads as a total delivery failure.\n */\n delivery:\n delivered === null ? null : campaignRate(delivered, sent, 'sent'),\n open: campaignRate(uniqueOpens ?? undefined, delivered ?? undefined, 'delivered'),\n click: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, delivered ?? undefined, 'delivered')\n : null,\n clickToOpen: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, uniqueOpens ?? undefined, 'unique openers')\n : null,\n bounce: campaignRate(bounced, sent, 'sent'),\n complaint: campaignRate(complained, delivered ?? undefined, 'delivered'),\n unsubscribe: campaignRate(unsubscribes, delivered ?? undefined, 'delivered'),\n }\n\n /*\n * The populations the SEND measured, reported as parts of a named whole\n * rather than as bare counts. \"412 withheld\" invites the question \"out of\n * what\"; the answer is the audience, and it is a different whole from the\n * one `suppressed` is measured against — consent is decided over the whole\n * audience and suppression over the capped recipient list, because that is\n * where each check runs. Netting them into one column would present two\n * different denominators as one.\n */\n const audienceSize = Number(source.audienceSize ?? 0)\n const populations: CampaignPopulation[] = []\n const addPopulation = (\n id: string,\n label: string,\n count: number | undefined,\n ofLabel: string,\n of: number,\n ) => {\n if (count === undefined) return\n populations.push({ id, label, count: Number(count), ofLabel, of })\n }\n addPopulation(\n 'consented',\n 'Had a consent basis',\n source.consented,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentedByOperator',\n 'Consent asserted by an operator',\n source.consentedByOperator,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'grandfathered',\n 'Reachable only because consent is not enforced retroactively',\n source.grandfathered,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentWithheld',\n 'Withheld by the consent rule',\n source.consentWithheld,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'suppressed',\n 'Already suppressed',\n source.suppressed,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'cadenceHeld',\n 'Asked for mail less often than this',\n source.cadenceHeld,\n 'addressed',\n recipients,\n )\n\n const caveats: CampaignCaveat[] = []\n if (delivered === null) {\n caveats.push({\n id: 'delivery-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so open, ' +\n 'click, complaint and unsubscribe rates cannot be computed — every ' +\n 'one of them is taken over delivered. Counts below are still real.',\n })\n }\n if (!clickTrackable) {\n caveats.push({\n id: 'click-tracking-unrecorded',\n message:\n 'This send did not record carrying an HTML part. Click tracking ' +\n 'rewrites links in the HTML, so a send without one reports zero ' +\n 'clicks whatever recipients did. The click count is shown; no click ' +\n 'rate is computed from it.',\n })\n }\n if (source.audienceSizeTruncated) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'Audience resolution stopped at its read ceiling, so the audience ' +\n 'size is a floor — the real audience is at least this large, and ' +\n 'every share taken over it is at most the figure shown.',\n })\n }\n if (Number(source.deferred ?? 0) > 0) {\n caveats.push({\n id: 'send-deferred',\n message:\n `${Number(source.deferred)} recipients were held back by the hourly ` +\n 'send governor and never received this campaign. They are counted in ' +\n 'addressed, not in sent.',\n })\n }\n\n return {\n sent,\n recipients,\n delivered,\n opens,\n clicks,\n uniqueOpens,\n uniqueClicks,\n bounced,\n complained,\n unsubscribes,\n rates,\n populations,\n caveats,\n }\n}\n\n/*==========================================\n * LINK-LEVEL CLICKS.\n *\n * Resend's `email.clicked` payload carries `data.click.link`, the destination\n * the recipient followed; `normalizeResendDeliveryEvents` already reads it\n * into `EmailDeliveryEvent.link`, and the per-recipient delivery log already\n * stores it. What did not exist was an aggregate — which is what \"link\n * clicks\" means, and it cannot be produced from the delivery log without\n * reading every recipient row for the campaign.\n *\n * So it is a WRITE-TIME rollup: one document per campaign,\n * `campaigns/{campaignId}/reports/links`, holding a bounded map. The report\n * reads exactly one document for the whole table.\n *=========================================*/\n\n/**\n * How many distinct destinations one campaign's rollup keeps.\n *\n * A CAP rather than a page size, and it exists because the map lives in a\n * single document with a 1 MiB ceiling. Clicks past the cap are counted in\n * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the\n * table's total still reconciles with `stats.clicks`.\n */\nexport const CAMPAIGN_LINK_ROLLUP_MAX = 50\n\n/**\n * Reduces a clicked URL to the key the rollup counts under.\n *\n * ## Why the query string is dropped\n *\n * Two reasons, and the second is the one that forces it:\n *\n * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a\n * link may carry a personalised query. Keying on the full URL would then\n * mint one rollup row per RECIPIENT — the aggregate degenerates into the\n * per-recipient log it exists to summarise, and it blows the cap on the\n * first campaign that does it.\n * 2. **A personalised query can carry the recipient's own address.** The\n * rollup is an aggregate read by everyone on the site's team; it must not\n * become a list of who clicked, and dropping the query is what guarantees\n * it cannot.\n *\n * ⚠️ The cost is real and is stated on the screen: two links to the same page\n * distinguished only by their UTM parameters count as ONE row. That is a\n * known limitation, not an oversight — see the note in the report card.\n *\n * @returns the normalized URL, or `null` for anything unparseable or not\n * http(s). A rollup key must be a URL a merchant recognises.\n */\nexport function campaignLinkKey(link: string | null | undefined): string | null {\n const raw = String(link ?? '').trim()\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n // Trailing slash normalised away so `/pricing` and `/pricing/` are one\n // row; the bare origin keeps its slash so the key is still a valid URL.\n const path = url.pathname.length > 1\n ? url.pathname.replace(/\\/+$/, '')\n : url.pathname\n return `${url.origin}${path}`\n } catch {\n return null\n }\n}\n\n/** One destination in the rollup. */\nexport interface CampaignLinkRow {\n url: string\n /** Click EVENTS on this destination. One reader clicking twice counts two. */\n clicks: number\n /** Share of the campaign's counted link clicks. */\n share: CampaignRate | null\n}\n\n/** The stored shape of `campaigns/{campaignId}/reports/links`. */\nexport interface CampaignLinkRollup {\n links?: Record<string, { url?: string; clicks?: number }>\n /** Clicks on destinations past {@link CAMPAIGN_LINK_ROLLUP_MAX}. */\n overflowClicks?: number\n /** Click events that arrived carrying no destination at all. */\n unattributedClicks?: number\n}\n\n/** What the link table renders. */\nexport interface CampaignLinkReport {\n rows: CampaignLinkRow[]\n /** Clicks counted against a named destination — the table's total. */\n attributedClicks: number\n overflowClicks: number\n unattributedClicks: number\n /** True once the cap bit, so the table says it is not the whole list. */\n truncated: boolean\n}\n\n/**\n * The link table, sorted by clicks descending.\n *\n * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —\n * and not over `stats.clicks`. The two differ by the overflow and the\n * unattributed, and a share column that did not sum to 100% because of rows\n * that are not on screen is the kind of arithmetic a reader cannot check.\n * Both excluded figures are returned so the screen can state them.\n */\nexport function campaignLinkReport(\n rollup: CampaignLinkRollup | undefined,\n): CampaignLinkReport {\n const entries = Object.values(rollup?.links ?? {})\n .map((entry) => ({\n url: String(entry?.url ?? ''),\n clicks: Number(entry?.clicks ?? 0),\n }))\n .filter((entry) => entry.url && Number.isFinite(entry.clicks))\n const attributedClicks = entries.reduce((total, one) => total + one.clicks, 0)\n const rows = [...entries]\n .sort((a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url))\n .map((entry) => ({\n ...entry,\n share: campaignRate(entry.clicks, attributedClicks, 'link clicks counted'),\n }))\n const overflowClicks = Number(rollup?.overflowClicks ?? 0)\n return {\n rows,\n attributedClicks,\n overflowClicks,\n unattributedClicks: Number(rollup?.unattributedClicks ?? 0),\n truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0,\n }\n}\n"],"names":["campaignRate","numerator","denominator","denominatorLabel","top","Number","bottom","isFinite","value","campaignReport","stats","source","sent","recipients","opens","clicks","bounced","complained","unsubscribes","delivered","undefined","uniqueOpens","uniqueClicks","clickTrackable","clickTracked","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","audienceSize","populations","addPopulation","id","label","count","ofLabel","of","push","consented","consentedByOperator","grandfathered","consentWithheld","suppressed","cadenceHeld","caveats","message","audienceSizeTruncated","deferred","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","link","raw","String","trim","url","URL","protocol","path","pathname","length","replace","origin","campaignLinkReport","rollup","entries","Object","values","links","map","entry","filter","attributedClicks","reduce","total","one","rows","sort","a","b","localeCompare","share","overflowClicks","unattributedClicks","truncated"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-report.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * CAMPAIGN REPORTING MATH — the only place a rate is computed.\n *\n * ## Why a pure module and not a component\n *\n * Every number on the report screen is a division, and a division is where\n * email reporting goes wrong. Putting the arithmetic in JSX means the\n * denominator is chosen by whoever writes the next card, in a file nobody\n * tests for arithmetic; putting it here means each rate is named once,\n * carries its own denominator as data, and is provable.\n *\n * ## The rule this module exists to enforce\n *\n * **A rate is a triple — numerator, denominator, and the NAME of the\n * denominator — or it is not reported.** An open rate over `sent` and an open\n * rate over `delivered` are different numbers with the same label, and the\n * gap between them is exactly the mail that bounced. The industry convention\n * is over `delivered`, and a report that quietly used `sent` would read\n * higher than the same campaign measured anywhere else.\n *\n * So {@link CampaignRate} carries `denominatorLabel`, and the screen is\n * required to render it. There is no overload that omits it.\n *\n * ## Why some rates are deliberately absent\n *\n * {@link campaignRate} answers `null`, not zero, when it cannot divide:\n *\n * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open\n * rate, it is no open rate. Rendering 0% invites the reader to compare it\n * with a campaign that really did fail.\n * - **An UNKNOWN denominator.** `delivered` is counted by the delivery\n * webhook, which was connected after some campaigns were sent. A campaign\n * with 400 sends and no delivery events has an unknown denominator, not a\n * denominator of zero — and dividing by `sent` instead is precisely the\n * flattering substitution above.\n *\n * ## The structural-zero window\n *\n * Click tracking rewrites links in the HTML part. Sends that carried no HTML\n * part were therefore untrackable, and every one of those campaigns reports 0\n * clicks whatever the recipients actually did — a real 0 and a structural 0\n * rendered identically. `send-email.ts` now synthesises an HTML part for a\n * text-only send, so every send after that carries one, and\n * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.\n *\n * A campaign with no such marker predates the record. Its click COUNT is\n * still shown — it is a real count of real events — but no click RATE is\n * computed from it, because a rate presents the number as a measurement of\n * the audience and for those campaigns it is a measurement of the sender.\n */\n\n/**\n * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.\n *\n * Every field is optional and every reader defaults it, because these are\n * written by three different writers at three different times — the send, the\n * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,\n * readable document from the instant the first of them lands.\n */\nexport interface CampaignStats {\n /*========================================\n * WRITTEN BY THE SEND. Never recomputed.\n *\n * These are the truth of what happened, recorded once by the code that did\n * it. Re-deriving any of them at read time would produce a number that\n * disagrees with the send — the audience has moved on since, suppressions\n * have been added, consent has been recorded — and the recorded one is the\n * one that describes the campaign.\n *=======================================*/\n /** The whole audience the send was taken from, before the per-send cap. */\n audienceSize?: number\n /** `audienceSize` is a FLOOR: audience resolution hit its read ceiling. */\n audienceSizeTruncated?: boolean\n /** Addresses this send ADDRESSED — the audience after the per-send cap. */\n recipients?: number\n /** Messages the provider accepted. The `sent` in \"sent/recipients\". */\n sent?: number\n /** Of the audience, how many carry a recorded marketing consent basis. */\n consented?: number\n /** Of `consented`, how many hold a basis an operator asserted for them. */\n consentedByOperator?: number\n /** Of the audience, how many are reachable only because enforcement is\n * not retroactive — the population a strict consent policy removes. */\n grandfathered?: number\n /** Of the audience, how many the consent rule refused to mail. */\n consentWithheld?: number\n /** Of `recipients`, how many were already suppressed (unsubscribed,\n * bounced or complained on an earlier send). */\n suppressed?: number\n /** Of `recipients`, how many asked this site for mail less often than this\n * send would have arrived. Still subscribed; reached by a later campaign. */\n cadenceHeld?: number\n /** Recipients the hourly send governor refused mid-batch. */\n deferred?: number\n /**\n * This send carried an HTML part, so its links were trackable.\n *\n * Absent on every campaign sent before the field existed — see the\n * structural-zero note in this module's header. Absent is NOT false; it is\n * \"not recorded\", and the report says so rather than guessing.\n */\n clickTracked?: boolean\n /** Per-variant send counts for an A/B campaign. */\n variantSends?: Record<string, number>\n\n /*========================================\n * WRITTEN BY THE DELIVERY WEBHOOK, as increments.\n *=======================================*/\n /** Messages the receiving server accepted. The rate DENOMINATOR. */\n delivered?: number\n /** Open EVENTS. One reader opening four times counts four. */\n opens?: number\n /** Click EVENTS. One reader clicking three links counts three. */\n clicks?: number\n /** Messages whose FIRST open was seen — distinct readers who opened. */\n uniqueOpens?: number\n /** Messages whose FIRST click was seen — distinct readers who clicked. */\n uniqueClicks?: number\n /** Messages that bounced, permanent and transient together. */\n bounced?: number\n /** Recipients who pressed \"report spam\". */\n complained?: number\n\n /*========================================\n * WRITTEN BY THE UNSUBSCRIBE HANDLER.\n *=======================================*/\n /** Recipients who unsubscribed through THIS campaign's link. */\n unsubscribes?: number\n}\n\n/**\n * One rate, with the denominator it was taken over named as data.\n *\n * `denominatorLabel` is not a display nicety. It is the field that makes two\n * numbers called \"open rate\" distinguishable, and the screen renders it\n * beside the percentage for that reason.\n */\nexport interface CampaignRate {\n /** 0–1. Multiply for display; the model never formats. */\n value: number\n numerator: number\n denominator: number\n /** Reader-facing name of the denominator, e.g. `'delivered'`. */\n denominatorLabel: string\n}\n\n/**\n * A rate, or `null` when one cannot honestly be taken.\n *\n * `null` on a zero or unknown denominator — see the module header for why\n * that is not the same as 0%.\n */\nexport function campaignRate(\n numerator: number | undefined,\n denominator: number | undefined,\n denominatorLabel: string,\n): CampaignRate | null {\n const top = Number(numerator ?? 0)\n const bottom = Number(denominator ?? 0)\n if (!Number.isFinite(top) || !Number.isFinite(bottom)) return null\n if (bottom <= 0) return null\n return {\n value: top / bottom,\n numerator: top,\n denominator: bottom,\n denominatorLabel,\n }\n}\n\n/** Why a number the report would otherwise show is being withheld. */\nexport interface CampaignCaveat {\n /** Stable id, so a spec can assert on the caveat rather than its prose. */\n id:\n | 'delivery-unrecorded'\n | 'click-tracking-unrecorded'\n | 'audience-truncated'\n | 'send-deferred'\n /* Raised by `campaign-revenue.ts`, which reports through this shape so\n * the screen has one way of saying \"a number is being withheld and here\n * is why\" rather than one per section. */\n | 'revenue-denominator-unrecorded'\n | 'revenue-multi-currency'\n | 'revenue-mid-flight'\n | 'revenue-mixed-model'\n /* Raised by `campaign-conversions.ts`, through this shape for the same\n * reason: one way of saying \"a number is being withheld, or must not be\n * read the obvious way, and here is why\". The first two are the reasons\n * the four conversion kinds stand apart instead of totalling; the last\n * two qualify the uncredited figure rather than withholding it. */\n | 'conversions-kinds-overlap'\n | 'conversions-web-not-rolled-up'\n | 'conversions-unattributed-is-a-ceiling'\n | 'conversions-total-crosses-hosts'\n /* Raised by the Sequences block below (AGL-3254): the five figures are\n * a funnel read across, never a sum. */\n | 'sequences-funnel-not-summed'\n message: string\n}\n\n/** One population the send measured, for the audience breakdown. */\nexport interface CampaignPopulation {\n id: string\n label: string\n count: number\n /** What this count is a part OF, named. */\n ofLabel: string\n of: number\n}\n\n/** Everything the report screen renders, decided here rather than in JSX. */\nexport interface CampaignReport {\n sent: number\n recipients: number\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n /** Accepted by the receiving server, over what the provider accepted. */\n delivery: CampaignRate | null\n /** Distinct readers who opened, over delivered. */\n open: CampaignRate | null\n /** Distinct readers who clicked, over delivered. */\n click: CampaignRate | null\n /**\n * Distinct clickers over distinct OPENERS — a different question from\n * `click`, and the one the two get confused for. It answers \"of the\n * people who read it, how many acted\", not \"of the people who received\n * it\". Reported separately and labelled separately, never as \"click\n * rate\".\n */\n clickToOpen: CampaignRate | null\n /** Bounced over what the provider accepted. */\n bounce: CampaignRate | null\n /** Complaints over delivered — the number mailbox providers judge on. */\n complaint: CampaignRate | null\n /** Unsubscribes through this campaign's link, over delivered. */\n unsubscribe: CampaignRate | null\n }\n populations: CampaignPopulation[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns a stored `stats` map into the report.\n *\n * ## The denominator decisions, in one place\n *\n * - **`delivered`** carries the engagement rates — open, click, complaint,\n * unsubscribe. Mail that bounced was never in front of a human, so\n * including it in the denominator of an open rate depresses a number that\n * describes the audience with a fact about the address list. This is also\n * the convention every other tool reports, which matters: a merchant\n * comparing our figure with their previous ESP's must be comparing the same\n * quantity.\n * - **`sent`** carries the delivery and bounce rates, because those describe\n * what happened to what we handed the provider, and `delivered` is the\n * numerator of one of them — a rate cannot be over itself.\n * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS\n * over recipients can exceed 100% the moment one person opens twice, and a\n * percentage above 100 is how a reader learns the number means something\n * other than what it says. Both are shown; only the distinct count is\n * divided.\n *\n * ## `delivered` unknown vs. zero\n *\n * A campaign predating the delivery webhook records no `delivered` at all.\n * That is reported as `null` and every rate over it is withheld, with a\n * caveat naming the reason — rather than substituting `sent`, which would\n * silently publish the flattered number this module exists to refuse.\n */\nexport function campaignReport(stats: CampaignStats | undefined): CampaignReport {\n const source = stats ?? {}\n const sent = Number(source.sent ?? 0)\n const recipients = Number(source.recipients ?? 0)\n const opens = Number(source.opens ?? 0)\n const clicks = Number(source.clicks ?? 0)\n const bounced = Number(source.bounced ?? 0)\n const complained = Number(source.complained ?? 0)\n const unsubscribes = Number(source.unsubscribes ?? 0)\n\n /*\n * ABSENT, not zero. `stats.delivered` is written only by the delivery\n * webhook, so `undefined` means \"no delivery event has ever been recorded\n * for this campaign\" — which for an old campaign means the webhook was not\n * connected, and for a campaign sent thirty seconds ago means the events\n * are still in flight. Neither is \"nothing was delivered\", and both are\n * ruined by `?? 0`, which would turn the unknown into a hard zero and make\n * every rate over it `null` for the RIGHT answer by the WRONG reasoning —\n * and would render \"0 delivered\" on screen beside \"500 sent\".\n */\n const delivered =\n source.delivered === undefined ? null : Number(source.delivered)\n const uniqueOpens =\n source.uniqueOpens === undefined ? null : Number(source.uniqueOpens)\n const uniqueClicks =\n source.uniqueClicks === undefined ? null : Number(source.uniqueClicks)\n\n /*\n * The click rate is withheld for a campaign that never recorded carrying an\n * HTML part, even when clicks are non-zero and `delivered` is known. See\n * the structural-zero note in the module header: for those sends 0 is the\n * only value the number could ever have taken, so a rate computed from it\n * measures our sending code rather than the recipients.\n */\n const clickTrackable = source.clickTracked === true\n\n const rates: CampaignReport['rates'] = {\n /*\n * `null` when `delivered` is UNRECORDED, and this is the one rate where\n * the numerator can be unknown rather than zero.\n *\n * Everywhere else an absent numerator is a genuine nought — a campaign\n * with delivery events and no opens really does have a 0% open rate, and\n * that is worth showing. Here the numerator IS the unrecorded quantity,\n * so `campaignRate(undefined, 1000, 'sent')` would divide a missing\n * measurement by a real one and publish \"0.0% delivery rate — 0 of 1,000\n * sent\" for a campaign whose delivery events were merely never recorded.\n * That is the flattering-substitution failure this module exists to\n * refuse, running in the other direction: not a rate that reads too high,\n * but a campaign that reads as a total delivery failure.\n */\n delivery:\n delivered === null ? null : campaignRate(delivered, sent, 'sent'),\n open: campaignRate(uniqueOpens ?? undefined, delivered ?? undefined, 'delivered'),\n click: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, delivered ?? undefined, 'delivered')\n : null,\n clickToOpen: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, uniqueOpens ?? undefined, 'unique openers')\n : null,\n bounce: campaignRate(bounced, sent, 'sent'),\n complaint: campaignRate(complained, delivered ?? undefined, 'delivered'),\n unsubscribe: campaignRate(unsubscribes, delivered ?? undefined, 'delivered'),\n }\n\n /*\n * The populations the SEND measured, reported as parts of a named whole\n * rather than as bare counts. \"412 withheld\" invites the question \"out of\n * what\"; the answer is the audience, and it is a different whole from the\n * one `suppressed` is measured against — consent is decided over the whole\n * audience and suppression over the capped recipient list, because that is\n * where each check runs. Netting them into one column would present two\n * different denominators as one.\n */\n const audienceSize = Number(source.audienceSize ?? 0)\n const populations: CampaignPopulation[] = []\n const addPopulation = (\n id: string,\n label: string,\n count: number | undefined,\n ofLabel: string,\n of: number,\n ) => {\n if (count === undefined) return\n populations.push({ id, label, count: Number(count), ofLabel, of })\n }\n addPopulation(\n 'consented',\n 'Had a consent basis',\n source.consented,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentedByOperator',\n 'Consent asserted by an operator',\n source.consentedByOperator,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'grandfathered',\n 'Reachable only because consent is not enforced retroactively',\n source.grandfathered,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentWithheld',\n 'Withheld by the consent rule',\n source.consentWithheld,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'suppressed',\n 'Already suppressed',\n source.suppressed,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'cadenceHeld',\n 'Asked for mail less often than this',\n source.cadenceHeld,\n 'addressed',\n recipients,\n )\n\n const caveats: CampaignCaveat[] = []\n if (delivered === null) {\n caveats.push({\n id: 'delivery-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so open, ' +\n 'click, complaint and unsubscribe rates cannot be computed — every ' +\n 'one of them is taken over delivered. Counts below are still real.',\n })\n }\n if (!clickTrackable) {\n caveats.push({\n id: 'click-tracking-unrecorded',\n message:\n 'This send did not record carrying an HTML part. Click tracking ' +\n 'rewrites links in the HTML, so a send without one reports zero ' +\n 'clicks whatever recipients did. The click count is shown; no click ' +\n 'rate is computed from it.',\n })\n }\n if (source.audienceSizeTruncated) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'Audience resolution stopped at its read ceiling, so the audience ' +\n 'size is a floor — the real audience is at least this large, and ' +\n 'every share taken over it is at most the figure shown.',\n })\n }\n if (Number(source.deferred ?? 0) > 0) {\n caveats.push({\n id: 'send-deferred',\n message:\n `${Number(source.deferred)} recipients were held back by the hourly ` +\n 'send governor and never received this campaign. They are counted in ' +\n 'addressed, not in sent.',\n })\n }\n\n return {\n sent,\n recipients,\n delivered,\n opens,\n clicks,\n uniqueOpens,\n uniqueClicks,\n bounced,\n complained,\n unsubscribes,\n rates,\n populations,\n caveats,\n }\n}\n\n/*==========================================\n * LINK-LEVEL CLICKS.\n *\n * Resend's `email.clicked` payload carries `data.click.link`, the destination\n * the recipient followed; `normalizeResendDeliveryEvents` already reads it\n * into `EmailDeliveryEvent.link`, and the per-recipient delivery log already\n * stores it. What did not exist was an aggregate — which is what \"link\n * clicks\" means, and it cannot be produced from the delivery log without\n * reading every recipient row for the campaign.\n *\n * So it is a WRITE-TIME rollup: one document per campaign,\n * `campaigns/{campaignId}/reports/links`, holding a bounded map. The report\n * reads exactly one document for the whole table.\n *=========================================*/\n\n/**\n * How many distinct destinations one campaign's rollup keeps.\n *\n * A CAP rather than a page size, and it exists because the map lives in a\n * single document with a 1 MiB ceiling. Clicks past the cap are counted in\n * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the\n * table's total still reconciles with `stats.clicks`.\n */\nexport const CAMPAIGN_LINK_ROLLUP_MAX = 50\n\n/**\n * Reduces a clicked URL to the key the rollup counts under.\n *\n * ## Why the query string is dropped\n *\n * Two reasons, and the second is the one that forces it:\n *\n * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a\n * link may carry a personalised query. Keying on the full URL would then\n * mint one rollup row per RECIPIENT — the aggregate degenerates into the\n * per-recipient log it exists to summarise, and it blows the cap on the\n * first campaign that does it.\n * 2. **A personalised query can carry the recipient's own address.** The\n * rollup is an aggregate read by everyone on the site's team; it must not\n * become a list of who clicked, and dropping the query is what guarantees\n * it cannot.\n *\n * ⚠️ The cost is real and is stated on the screen: two links to the same page\n * distinguished only by their UTM parameters count as ONE row. That is a\n * known limitation, not an oversight — see the note in the report card.\n *\n * @returns the normalized URL, or `null` for anything unparseable or not\n * http(s). A rollup key must be a URL a merchant recognises.\n */\nexport function campaignLinkKey(link: string | null | undefined): string | null {\n const raw = String(link ?? '').trim()\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n // Trailing slash normalised away so `/pricing` and `/pricing/` are one\n // row; the bare origin keeps its slash so the key is still a valid URL.\n const path = url.pathname.length > 1\n ? url.pathname.replace(/\\/+$/, '')\n : url.pathname\n return `${url.origin}${path}`\n } catch {\n return null\n }\n}\n\n/** One destination in the rollup. */\nexport interface CampaignLinkRow {\n url: string\n /** Click EVENTS on this destination. One reader clicking twice counts two. */\n clicks: number\n /** Share of the campaign's counted link clicks. */\n share: CampaignRate | null\n}\n\n/** The stored shape of `campaigns/{campaignId}/reports/links`. */\nexport interface CampaignLinkRollup {\n links?: Record<string, { url?: string; clicks?: number }>\n /** Clicks on destinations past {@link CAMPAIGN_LINK_ROLLUP_MAX}. */\n overflowClicks?: number\n /** Click events that arrived carrying no destination at all. */\n unattributedClicks?: number\n}\n\n/** What the link table renders. */\nexport interface CampaignLinkReport {\n rows: CampaignLinkRow[]\n /** Clicks counted against a named destination — the table's total. */\n attributedClicks: number\n overflowClicks: number\n unattributedClicks: number\n /** True once the cap bit, so the table says it is not the whole list. */\n truncated: boolean\n}\n\n/**\n * The link table, sorted by clicks descending.\n *\n * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —\n * and not over `stats.clicks`. The two differ by the overflow and the\n * unattributed, and a share column that did not sum to 100% because of rows\n * that are not on screen is the kind of arithmetic a reader cannot check.\n * Both excluded figures are returned so the screen can state them.\n */\nexport function campaignLinkReport(\n rollup: CampaignLinkRollup | undefined,\n): CampaignLinkReport {\n const entries = Object.values(rollup?.links ?? {})\n .map((entry) => ({\n url: String(entry?.url ?? ''),\n clicks: Number(entry?.clicks ?? 0),\n }))\n .filter((entry) => entry.url && Number.isFinite(entry.clicks))\n const attributedClicks = entries.reduce((total, one) => total + one.clicks, 0)\n const rows = [...entries]\n .sort((a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url))\n .map((entry) => ({\n ...entry,\n share: campaignRate(entry.clicks, attributedClicks, 'link clicks counted'),\n }))\n const overflowClicks = Number(rollup?.overflowClicks ?? 0)\n return {\n rows,\n attributedClicks,\n overflowClicks,\n unattributedClicks: Number(rollup?.unattributedClicks ?? 0),\n truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0,\n }\n}\n\n/*==========================================\n * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does — `campaignIds` on its own\n * document — and its outcomes are then the campaign's to report: every\n * person enrolled, the first email each one was sent, the replies, the\n * meetings booked from a sequence link, and the enrolled leads that\n * converted. The Outreach runtime credits each outcome once per enrollment\n * to every campaign the sequence was in when the person was enrolled, into\n * `hosts/{hostId}/campaignSequenceReports/{campaignId}` — one document per\n * campaign, beside the conversions rollup and never inside the campaign\n * document, which the history list and the glance widget read.\n *\n * ## The five figures are a funnel, not a sum\n *\n * Each figure counts ENROLLMENTS at a stage: 40 enrolled, 38 sent, 6\n * replied, 2 meetings, 1 converted. They are read across, never added — a\n * person is in every stage they reached — and the model keeps them as an\n * ordered list rather than a total for the reason the conversion kinds are\n * kept apart.\n *\n * ## Absent is not zero\n *\n * A campaign no sequence was ever in has no document, and a stage nobody\n * reached has no field. Both are `null` here, so the screen can say\n * \"nothing recorded\" for the first and draw a dash for the second, rather\n * than printing a measured 0 replies for a campaign whose sequence went\n * out yesterday.\n *=========================================*/\n\n/**\n * The outcomes the runtime credits, in funnel order. An array first, because\n * the ORDER is the reading order and a second list is a second chance to\n * leave one out; the union is derived from it.\n */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/** The per-host collection holding one document per campaign. */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** What a reader calls each outcome, and what the count means. */\nexport const CAMPAIGN_SEQUENCE_OUTCOME_COPY: Readonly<\n Record<CampaignSequenceOutcome, { label: string; note: string }>\n> = {\n enrolled: { label: 'Enrolled', note: 'people enrolled in a sequence in this campaign' },\n sent: { label: 'Sent', note: 'of them, sent their first sequence email' },\n replied: { label: 'Replied', note: 'of them, who wrote back' },\n meetings: { label: 'Meetings', note: 'bookings made from a sequence link' },\n converted: { label: 'Converted', note: 'enrolled leads that became contacts' },\n}\n\n/**\n * The stored shape of `campaignSequenceReports/{campaignId}`.\n *\n * Read-side only, every field optional: another plugin's runtime wrote it,\n * and a reader that assumed a field was present would throw on the first\n * document written before the field existed.\n */\nexport interface CampaignSequencesRollup {\n byOutcome?: Partial<Record<CampaignSequenceOutcome, number>>\n /** When the runtime last credited anything, epoch ms. */\n updatedAtMs?: number\n}\n\n/** One outcome's figure on screen. */\nexport interface CampaignSequenceFigure {\n outcome: CampaignSequenceOutcome\n label: string\n /** The count, or `null` when the rollup holds no entry for the outcome. */\n value: number | null\n note: string\n}\n\n/** Everything the Sequences section renders. */\nexport interface CampaignSequencesReport {\n /** Always all five, always in {@link CAMPAIGN_SEQUENCE_OUTCOMES} order. */\n figures: CampaignSequenceFigure[]\n /** Whether the rollup document exists at all — see the block header. */\n recorded: boolean\n /** At least one outcome holds a figure. */\n any: boolean\n caveats: CampaignCaveat[]\n}\n\n/** Turns the stored rollup into the Sequences section. */\nexport function campaignSequencesReport(\n rollup: CampaignSequencesRollup | undefined,\n): CampaignSequencesReport {\n const stored = rollup?.byOutcome ?? {}\n const figures: CampaignSequenceFigure[] = CAMPAIGN_SEQUENCE_OUTCOMES.map((outcome) => {\n const raw = stored[outcome]\n const value = Math.floor(Number(raw ?? 0))\n return {\n outcome,\n label: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].label,\n // Unrecorded, negative and non-numeric all read as \"no figure\": the\n // runtime only ever increments, so a stored 0 is not a count either.\n value: raw === undefined || !Number.isFinite(value) || value <= 0 ? null : value,\n note: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].note,\n }\n })\n const any = figures.some((figure) => figure.value !== null)\n const caveats: CampaignCaveat[] = any\n ? [\n {\n id: 'sequences-funnel-not-summed',\n message:\n 'Each figure counts the people who reached that stage, so a person ' +\n 'who replied is counted under Enrolled and Sent as well. Read them ' +\n 'across; they are deliberately not added together.',\n },\n ]\n : []\n return { figures, recorded: rollup !== undefined, any, caveats }\n}\n"],"names":["campaignRate","numerator","denominator","denominatorLabel","top","Number","bottom","isFinite","value","campaignReport","stats","source","sent","recipients","opens","clicks","bounced","complained","unsubscribes","delivered","undefined","uniqueOpens","uniqueClicks","clickTrackable","clickTracked","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","audienceSize","populations","addPopulation","id","label","count","ofLabel","of","push","consented","consentedByOperator","grandfathered","consentWithheld","suppressed","cadenceHeld","caveats","message","audienceSizeTruncated","deferred","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","link","raw","String","trim","url","URL","protocol","path","pathname","length","replace","origin","campaignLinkReport","rollup","entries","Object","values","links","map","entry","filter","attributedClicks","reduce","total","one","rows","sort","a","b","localeCompare","share","overflowClicks","unattributedClicks","truncated","CAMPAIGN_SEQUENCE_OUTCOMES","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOME_COPY","enrolled","note","replied","meetings","converted","campaignSequencesReport","stored","byOutcome","figures","outcome","Math","floor","any","some","figure","recorded"],"mappings":"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"}