@aglyn/shared-ui-email-campaigns 1.0.0-beta.143

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.
Files changed (33) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -0
  3. package/package.json +39 -0
  4. package/src/index.d.ts +17 -0
  5. package/src/index.js +23 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-picker.component.d.ts +54 -0
  8. package/src/lib/components/campaign-picker.component.js +121 -0
  9. package/src/lib/components/campaign-picker.component.js.map +1 -0
  10. package/src/lib/components/report-figures.d.ts +51 -0
  11. package/src/lib/components/report-figures.js +120 -0
  12. package/src/lib/components/report-figures.js.map +1 -0
  13. package/src/lib/model/campaign-container.d.ts +359 -0
  14. package/src/lib/model/campaign-container.js +355 -0
  15. package/src/lib/model/campaign-container.js.map +1 -0
  16. package/src/lib/model/campaign-conversions.d.ts +286 -0
  17. package/src/lib/model/campaign-conversions.js +249 -0
  18. package/src/lib/model/campaign-conversions.js.map +1 -0
  19. package/src/lib/model/campaign-report.d.ts +304 -0
  20. package/src/lib/model/campaign-report.js +326 -0
  21. package/src/lib/model/campaign-report.js.map +1 -0
  22. package/src/lib/model/campaign-revenue.d.ts +327 -0
  23. package/src/lib/model/campaign-revenue.js +332 -0
  24. package/src/lib/model/campaign-revenue.js.map +1 -0
  25. package/src/lib/model/campaign-send-time.d.ts +74 -0
  26. package/src/lib/model/campaign-send-time.js +120 -0
  27. package/src/lib/model/campaign-send-time.js.map +1 -0
  28. package/src/lib/model/email-record.d.ts +175 -0
  29. package/src/lib/model/email-record.js +198 -0
  30. package/src/lib/model/email-record.js.map +1 -0
  31. package/src/lib/model/index.d.ts +53 -0
  32. package/src/lib/model/index.js +48 -0
  33. package/src/lib/model/index.js.map +1 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { CampaignCaveat } from './campaign-report';
18
+ /**
19
+ * WHAT A CAMPAIGN CAUSED — the read half of the identify-moment join.
20
+ *
21
+ * `campaign-revenue.ts` answers what a campaign EARNED, from orders that name
22
+ * their buyer. This answers what it CAUSED among people who were anonymous
23
+ * until the moment they became somebody: they arrive from a campaign link,
24
+ * browse, and only become identifiable when they submit a form, sign up, book
25
+ * or check out. Same window, same last-click rule, same `model`/`windowDays`
26
+ * stamped on every record, so the two figures on one screen are two readings
27
+ * of one rule rather than two rules that happen to agree.
28
+ *
29
+ * ## THE KINDS ARE NEVER SUMMED, and this module makes that structural
30
+ *
31
+ * One form submission by a new person writes a submission record, a contact
32
+ * and a lead — three true statements about one visitor action. Adding them
33
+ * would treble every campaign's conversions, and the reader would have no way
34
+ * to see it had happened, because the sum looks like a bigger version of a
35
+ * real number.
36
+ *
37
+ * So {@link CampaignConversionsReport} carries no total, no `all`, no
38
+ * `conversions` scalar, and there is no function here that reduces the kinds.
39
+ * A screen that wanted one would have to write the addition itself. The
40
+ * report also raises {@link CampaignCaveat} `conversions-kinds-overlap` so
41
+ * the reader is told WHY the four figures stand apart, rather than being left
42
+ * to assume the product forgot to add them up.
43
+ *
44
+ * ## THE WEB CHANNEL HAS NO ROLLUP, and cannot be given one
45
+ *
46
+ * A conversion is credited to one of two touches. An EMAIL touch names a
47
+ * campaign document — a real entity with a real id — so its conversions roll
48
+ * up under it beside the revenue the same join credits it with. A WEB touch
49
+ * is a `utm_` label a marketer typed into a URL: no document, no id, and no
50
+ * bound on how many distinct values exist, because anybody who can vary a
51
+ * query string can mint another one.
52
+ *
53
+ * A rollup keyed on that label is a map a stranger can grow, which is the
54
+ * same unbounded key space the analytics collector caps its per-day label map
55
+ * against. So the writer increments the rollup for the email channel ONLY,
56
+ * this reader states that where the figure is drawn, and web-channel records
57
+ * are read as records — see {@link campaignConversionsCoverage}.
58
+ *
59
+ * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL
60
+ *
61
+ * Direct traffic writes no record. There is deliberately no
62
+ * `utm_source=direct` placeholder, no referrer inference and no "most recent
63
+ * campaign on this site" fallback: a conversion nobody can be credited with
64
+ * is a conversion nobody is credited with.
65
+ *
66
+ * That makes the absence of a record load-bearing, and it makes a screen
67
+ * showing only attributed conversions a lie by omission — it renders "we
68
+ * credited three of these" as "three of these happened".
69
+ * {@link campaignConversionsCoverage} exists so the unattributed count is a
70
+ * figure on the page rather than an inference nobody makes.
71
+ */
72
+ /**
73
+ * Which identify moment a record credits.
74
+ *
75
+ * An array first, because the ORDER is the reading order on every screen and
76
+ * a second list of these is a second chance to leave one out. The union is
77
+ * derived from it rather than written twice.
78
+ */
79
+ export declare const CAMPAIGN_CONVERSION_KINDS: readonly ["form", "lead", "contact", "booking"];
80
+ /** One identify moment. Mirrors the writer's `CampaignConversionKind`. */
81
+ export type CampaignConversionKind = (typeof CAMPAIGN_CONVERSION_KINDS)[number];
82
+ /** Which channel the credited touch arrived through. */
83
+ export type CampaignTouchChannel = 'email' | 'web';
84
+ /**
85
+ * What a reader calls one kind, and what the count means.
86
+ *
87
+ * The note is the half that stops the addition. Each one names a DIFFERENT
88
+ * population of the same visitors, and saying so under every figure is what
89
+ * makes four numbers standing apart read as deliberate rather than as a
90
+ * missing total.
91
+ */
92
+ export interface CampaignConversionKindCopy {
93
+ label: string;
94
+ note: string;
95
+ }
96
+ export declare const CAMPAIGN_CONVERSION_KIND_COPY: Readonly<Record<CampaignConversionKind, CampaignConversionKindCopy>>;
97
+ /**
98
+ * The stored shape of `campaigns/{campaignId}/reports/conversions`.
99
+ *
100
+ * Its own document beside `reports/revenue`, for the reason that one is: the
101
+ * campaign document is read by the history list, the glance widget and the
102
+ * send path, and a map that grows with what the campaign caused would enlarge
103
+ * every one of those reads.
104
+ */
105
+ export interface CampaignConversionsRollup {
106
+ byKind?: Partial<Record<CampaignConversionKind, number>>;
107
+ /** The model these conversions were credited under. */
108
+ model?: string;
109
+ /** The window, in days, they were credited inside. */
110
+ windowDays?: number;
111
+ }
112
+ /**
113
+ * The stored shape of `hosts/{hostId}/campaignAttributions/{kind}:{refId}`.
114
+ *
115
+ * Read-side only, and every field optional: this is a document somebody
116
+ * else's writer produced, and a reader that assumed a field was present would
117
+ * throw on the first record written by an older version of it.
118
+ */
119
+ export interface CampaignConversionRecord {
120
+ kind?: CampaignConversionKind;
121
+ /** The submission, lead, contact or booking this credits. */
122
+ refId?: string;
123
+ channel?: CampaignTouchChannel;
124
+ /** The campaign document, when the touch was a click on our own mail. */
125
+ campaignId?: string;
126
+ /** `utm_source`, when the touch was a link on the web. */
127
+ source?: string;
128
+ /** `utm_medium`, when the touch was a link on the web. */
129
+ medium?: string;
130
+ /** `utm_campaign`, when the touch was a link on the web. */
131
+ campaign?: string;
132
+ /** When the visitor followed the campaign link, epoch ms. */
133
+ touchedAtMs?: number;
134
+ /** When the visitor became identifiable, epoch ms. */
135
+ convertedAtMs?: number;
136
+ model?: string;
137
+ windowDays?: number;
138
+ }
139
+ /**
140
+ * The document id for one conversion — `{kind}:{refId}`.
141
+ *
142
+ * The reader's half of the writer's `campaignConversionId`. It is restated
143
+ * here rather than imported because the writer lives in `tenant-data-admin`,
144
+ * which a UI library may not import, and it is one line whose shape is
145
+ * asserted in this module's spec.
146
+ *
147
+ * Answers `null` for an unusable pair rather than building `form:undefined`,
148
+ * which would be a valid document path pointing at a record that can never
149
+ * exist — a keyed read that silently reports "not attributed" for every
150
+ * record on the screen.
151
+ */
152
+ export declare function campaignConversionId(kind: string | null | undefined, refId: string | null | undefined): string | null;
153
+ /** One kind's figure on screen. Independent of every other kind's. */
154
+ export interface CampaignConversionKindReport {
155
+ kind: CampaignConversionKind;
156
+ label: string;
157
+ /**
158
+ * Conversions of this kind, or `null` when the rollup holds no entry.
159
+ *
160
+ * `null` rather than 0 for {@link CampaignConversionsReport.recorded}'s
161
+ * reason, applied per kind: a site with no booking form has never written a
162
+ * booking conversion, and rendering that as a measured zero invites the
163
+ * reader to conclude the campaign failed at something it never attempted.
164
+ */
165
+ value: number | null;
166
+ note: string;
167
+ }
168
+ /** Everything the conversions section renders. */
169
+ export interface CampaignConversionsReport {
170
+ /**
171
+ * One entry per kind, always all four and always in
172
+ * {@link CAMPAIGN_CONVERSION_KINDS} order.
173
+ *
174
+ * There is deliberately NO total beside this array, and no field anywhere
175
+ * on this report that holds one. See the module docblock.
176
+ */
177
+ kinds: CampaignConversionKindReport[];
178
+ /**
179
+ * Whether the rollup document exists at all.
180
+ *
181
+ * `false` is NOT "this campaign caused nothing" — it is also every campaign
182
+ * sent before the join existed, and every campaign whose conversions all
183
+ * arrived through the web channel, which writes no rollup. The screen
184
+ * renders the difference rather than printing a zero for all three.
185
+ */
186
+ recorded: boolean;
187
+ /** At least one kind holds a figure. */
188
+ any: boolean;
189
+ /** The model these figures were credited under, as stored. */
190
+ model: string;
191
+ /** The window they were credited inside, as stored. */
192
+ windowDays: number;
193
+ caveats: CampaignCaveat[];
194
+ }
195
+ /**
196
+ * Turns the stored rollup into the conversions section.
197
+ *
198
+ * Takes the rollup and nothing else. Unlike the revenue report there is no
199
+ * denominator to hand in: a conversion RATE over delivered messages would be
200
+ * the same defect the revenue section refuses for orders — one visitor can
201
+ * submit two forms, so the quotient passes 100% without anything being wrong
202
+ * — and counting distinct people would need a document per person per
203
+ * campaign, the per-recipient read this whole surface exists to refuse.
204
+ */
205
+ export declare function campaignConversionsReport(options: {
206
+ rollup: CampaignConversionsRollup | undefined;
207
+ }): CampaignConversionsReport;
208
+ /**
209
+ * How much of one kind was credited to anything at all.
210
+ *
211
+ * ## Why this figure has to exist
212
+ *
213
+ * The join writes nothing for a conversion it cannot credit, so a list of
214
+ * attribution records is a list of the SUCCESSES. Rendering only that turns
215
+ * "we credited four of these" into "four of these happened", and the reader
216
+ * draws a conclusion about their campaigns from a number that is mostly a
217
+ * fact about how many visitors arrived without a campaign link.
218
+ *
219
+ * So the screen shows both halves and this computes the second. `attributed`
220
+ * is counted over the attribution records; `total` over the records the
221
+ * conversions themselves live in.
222
+ *
223
+ * ## Why the difference is a CEILING and says so
224
+ *
225
+ * The two counts come from different collections with different histories,
226
+ * and the gap between them holds three things that are not the same:
227
+ *
228
+ * - conversions by visitors who arrived directly, which is what the figure
229
+ * is meant to describe;
230
+ * - conversions from before the join existed, which were never eligible; and
231
+ * - for contacts, conversions on the org's OTHER sites, because contacts are
232
+ * shared across an org while attributions are per host.
233
+ *
234
+ * None of those can be separated from the others without a field nobody
235
+ * writes, so `unattributed` is reported as an upper bound and
236
+ * {@link CampaignConversionsCoverage.exact} is `false` whenever the reasons
237
+ * apply. A figure presented as exact when it is not is the failure this
238
+ * whole surface is built to avoid.
239
+ */
240
+ export interface CampaignConversionsCoverage {
241
+ kind: CampaignConversionKind;
242
+ /** Conversions of this kind credited to some campaign or label. */
243
+ attributed: number;
244
+ /** Conversions of this kind that exist, credited or not. */
245
+ total: number;
246
+ /** `total - attributed`, clamped at zero. An upper bound, not a count. */
247
+ unattributed: number;
248
+ /**
249
+ * Whether `unattributed` may be read as "arrived directly".
250
+ *
251
+ * Always `false` today — the three reasons in the docblock all apply to
252
+ * every host — and kept as a field rather than hardcoded in the copy so a
253
+ * screen asks the model rather than restating its conclusion.
254
+ */
255
+ exact: boolean;
256
+ caveats: CampaignCaveat[];
257
+ }
258
+ /**
259
+ * The attributed/unattributed split for one kind.
260
+ *
261
+ * `total` is `null` when it could not be counted — the aggregation failed, or
262
+ * the collection is not readable from this surface — and the split is then
263
+ * withheld entirely rather than defaulting `total` to `attributed`, which
264
+ * would render every conversion as attributed and is the single most
265
+ * flattering wrong answer available here.
266
+ */
267
+ export declare function campaignConversionsCoverage(options: {
268
+ kind: CampaignConversionKind;
269
+ attributed: number | null | undefined;
270
+ total: number | null | undefined;
271
+ /** The kind's records live outside this host, so `total` over-counts. */
272
+ crossHostTotal?: boolean;
273
+ }): CampaignConversionsCoverage | null;
274
+ /**
275
+ * How a record names the thing it was credited to.
276
+ *
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.
280
+ *
281
+ * The `utm_` triple is joined in the order a marketer set it, with the parts
282
+ * that are absent left out rather than filled with a placeholder.
283
+ * `google / cpc` and `google / cpc / (none)` describe the same link, and only
284
+ * one of them invites the reader to look for a campaign called "(none)".
285
+ */
286
+ export declare function campaignTouchLabel(record: CampaignConversionRecord | null | undefined): string;
@@ -0,0 +1,249 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*
17
+ * The window and the model name come from `@aglyn/shared-util-email`, for the
18
+ * reason `campaign-revenue.ts` gives: the writer is in `tenant-data-admin`,
19
+ * which may not import a feature plugin, and a rule defined on both sides of
20
+ * the join drifts into a figure credited under one and printed under another.
21
+ */ import { EMAIL_ATTRIBUTION_MODEL, EMAIL_ATTRIBUTION_WINDOW_DAYS } from "@aglyn/shared-util-email";
22
+ /**
23
+ * WHAT A CAMPAIGN CAUSED — the read half of the identify-moment join.
24
+ *
25
+ * `campaign-revenue.ts` answers what a campaign EARNED, from orders that name
26
+ * their buyer. This answers what it CAUSED among people who were anonymous
27
+ * until the moment they became somebody: they arrive from a campaign link,
28
+ * browse, and only become identifiable when they submit a form, sign up, book
29
+ * or check out. Same window, same last-click rule, same `model`/`windowDays`
30
+ * stamped on every record, so the two figures on one screen are two readings
31
+ * of one rule rather than two rules that happen to agree.
32
+ *
33
+ * ## THE KINDS ARE NEVER SUMMED, and this module makes that structural
34
+ *
35
+ * One form submission by a new person writes a submission record, a contact
36
+ * and a lead — three true statements about one visitor action. Adding them
37
+ * would treble every campaign's conversions, and the reader would have no way
38
+ * to see it had happened, because the sum looks like a bigger version of a
39
+ * real number.
40
+ *
41
+ * So {@link CampaignConversionsReport} carries no total, no `all`, no
42
+ * `conversions` scalar, and there is no function here that reduces the kinds.
43
+ * A screen that wanted one would have to write the addition itself. The
44
+ * report also raises {@link CampaignCaveat} `conversions-kinds-overlap` so
45
+ * the reader is told WHY the four figures stand apart, rather than being left
46
+ * to assume the product forgot to add them up.
47
+ *
48
+ * ## THE WEB CHANNEL HAS NO ROLLUP, and cannot be given one
49
+ *
50
+ * A conversion is credited to one of two touches. An EMAIL touch names a
51
+ * campaign document — a real entity with a real id — so its conversions roll
52
+ * up under it beside the revenue the same join credits it with. A WEB touch
53
+ * is a `utm_` label a marketer typed into a URL: no document, no id, and no
54
+ * bound on how many distinct values exist, because anybody who can vary a
55
+ * query string can mint another one.
56
+ *
57
+ * A rollup keyed on that label is a map a stranger can grow, which is the
58
+ * same unbounded key space the analytics collector caps its per-day label map
59
+ * against. So the writer increments the rollup for the email channel ONLY,
60
+ * this reader states that where the figure is drawn, and web-channel records
61
+ * are read as records — see {@link campaignConversionsCoverage}.
62
+ *
63
+ * ## A CONVERSION WITH NO TOUCH IS NOT IN HERE AT ALL
64
+ *
65
+ * Direct traffic writes no record. There is deliberately no
66
+ * `utm_source=direct` placeholder, no referrer inference and no "most recent
67
+ * campaign on this site" fallback: a conversion nobody can be credited with
68
+ * is a conversion nobody is credited with.
69
+ *
70
+ * That makes the absence of a record load-bearing, and it makes a screen
71
+ * showing only attributed conversions a lie by omission — it renders "we
72
+ * credited three of these" as "three of these happened".
73
+ * {@link campaignConversionsCoverage} exists so the unattributed count is a
74
+ * figure on the page rather than an inference nobody makes.
75
+ */ /*
76
+ * The constants are NOT re-exported here, though `campaign-revenue.ts`
77
+ * re-exports the same two. Both modules sit behind one barrel, and a name
78
+ * exported twice through it is a name a bundler has to disambiguate. Every
79
+ * screen reads the rule off `model`/`windowDays` on the report anyway, which
80
+ * is the stored value rather than today's constant — a campaign credited
81
+ * under an older window has to print the window it was credited under.
82
+ */ /**
83
+ * Which identify moment a record credits.
84
+ *
85
+ * An array first, because the ORDER is the reading order on every screen and
86
+ * a second list of these is a second chance to leave one out. The union is
87
+ * derived from it rather than written twice.
88
+ */ export const CAMPAIGN_CONVERSION_KINDS = [
89
+ 'form',
90
+ 'lead',
91
+ 'contact',
92
+ 'booking'
93
+ ];
94
+ export const CAMPAIGN_CONVERSION_KIND_COPY = {
95
+ form: {
96
+ label: 'Form submissions',
97
+ note: 'submissions credited to this campaign'
98
+ },
99
+ lead: {
100
+ label: 'Leads',
101
+ note: 'new leads credited to this campaign'
102
+ },
103
+ contact: {
104
+ label: 'Contacts',
105
+ note: 'new contacts credited to this campaign'
106
+ },
107
+ booking: {
108
+ label: 'Bookings',
109
+ note: 'bookings credited to this campaign'
110
+ }
111
+ };
112
+ /**
113
+ * The document id for one conversion — `{kind}:{refId}`.
114
+ *
115
+ * The reader's half of the writer's `campaignConversionId`. It is restated
116
+ * here rather than imported because the writer lives in `tenant-data-admin`,
117
+ * which a UI library may not import, and it is one line whose shape is
118
+ * asserted in this module's spec.
119
+ *
120
+ * Answers `null` for an unusable pair rather than building `form:undefined`,
121
+ * which would be a valid document path pointing at a record that can never
122
+ * exist — a keyed read that silently reports "not attributed" for every
123
+ * record on the screen.
124
+ */ export function campaignConversionId(kind, refId) {
125
+ const k = String(kind != null ? kind : '');
126
+ const ref = String(refId != null ? refId : '');
127
+ if (!k || !ref) return null;
128
+ if (!CAMPAIGN_CONVERSION_KINDS.includes(k)) return null;
129
+ // A slash would leave the collection; the id scheme already spends the
130
+ // colon, and a ref carrying one would make the pair ambiguous.
131
+ if (ref.includes('/') || ref.includes(':')) return null;
132
+ return `${k}:${ref}`;
133
+ }
134
+ /** A stored count as a non-negative integer. */ function count(raw) {
135
+ const value = Math.floor(Number(raw != null ? raw : 0));
136
+ return Number.isFinite(value) && value > 0 ? value : 0;
137
+ }
138
+ /**
139
+ * Turns the stored rollup into the conversions section.
140
+ *
141
+ * Takes the rollup and nothing else. Unlike the revenue report there is no
142
+ * denominator to hand in: a conversion RATE over delivered messages would be
143
+ * the same defect the revenue section refuses for orders — one visitor can
144
+ * submit two forms, so the quotient passes 100% without anything being wrong
145
+ * — and counting distinct people would need a document per person per
146
+ * campaign, the per-recipient read this whole surface exists to refuse.
147
+ */ export function campaignConversionsReport(options) {
148
+ var _ref, _ref1;
149
+ const { rollup } = options;
150
+ const stored = (_ref = rollup == null ? void 0 : rollup.byKind) != null ? _ref : {};
151
+ const caveats = [];
152
+ const kinds = CAMPAIGN_CONVERSION_KINDS.map((kind)=>{
153
+ const raw = stored[kind];
154
+ const copy = CAMPAIGN_CONVERSION_KIND_COPY[kind];
155
+ return {
156
+ kind,
157
+ label: copy.label,
158
+ // `undefined` means the rollup has no entry for this kind, which is
159
+ // not a measured zero. A stored 0 is impossible — the writer only
160
+ // ever increments — but it is read as unrecorded for the same reason.
161
+ value: raw === undefined || count(raw) === 0 ? null : count(raw),
162
+ note: copy.note
163
+ };
164
+ });
165
+ const any = kinds.some((entry)=>entry.value !== null);
166
+ if (any) {
167
+ caveats.push({
168
+ id: 'conversions-kinds-overlap',
169
+ message: 'These figures count different things about the same visits and are ' + 'deliberately not added together. One person filling in one form can ' + 'appear as a submission, a contact and a lead, so a total would ' + 'count that visit three times.'
170
+ });
171
+ caveats.push({
172
+ id: 'conversions-web-not-rolled-up',
173
+ message: 'Campaign emails only. A conversion credited to a link tagged with ' + 'utm_ parameters is recorded against that label rather than against ' + 'a campaign, so it is not in the figures above — those conversions ' + 'are listed under Conversions in the marketing console.'
174
+ });
175
+ }
176
+ return {
177
+ kinds,
178
+ recorded: rollup !== undefined,
179
+ any,
180
+ model: String((_ref1 = rollup == null ? void 0 : rollup.model) != null ? _ref1 : EMAIL_ATTRIBUTION_MODEL),
181
+ windowDays: count(rollup == null ? void 0 : rollup.windowDays) || EMAIL_ATTRIBUTION_WINDOW_DAYS,
182
+ caveats
183
+ };
184
+ }
185
+ /**
186
+ * The attributed/unattributed split for one kind.
187
+ *
188
+ * `total` is `null` when it could not be counted — the aggregation failed, or
189
+ * the collection is not readable from this surface — and the split is then
190
+ * withheld entirely rather than defaulting `total` to `attributed`, which
191
+ * would render every conversion as attributed and is the single most
192
+ * flattering wrong answer available here.
193
+ */ export function campaignConversionsCoverage(options) {
194
+ const { kind, crossHostTotal } = options;
195
+ if (options.total == null || options.attributed == null) return null;
196
+ const attributed = count(options.attributed);
197
+ const total = count(options.total);
198
+ const caveats = [];
199
+ /*
200
+ * CLAMPED, and only here at the point of display. The two counts are taken
201
+ * from two collections a moment apart, so a conversion recorded between
202
+ * them makes `attributed` briefly exceed `total`; a negative count of
203
+ * things that did not happen is not a sentence anybody can act on.
204
+ */ const unattributed = Math.max(0, total - attributed);
205
+ caveats.push({
206
+ id: 'conversions-unattributed-is-a-ceiling',
207
+ message: `${unattributed.toLocaleString()} of these are not credited to any ` + 'campaign. Most arrived without following a campaign link and are ' + 'recorded as direct — nothing is guessed from a referrer, and no ' + 'campaign is credited for being the most recent one to run. The rest ' + 'are records from before campaign attribution was recorded at all, so ' + 'read this as an upper bound rather than a count of direct arrivals.'
208
+ });
209
+ if (crossHostTotal) {
210
+ caveats.push({
211
+ id: 'conversions-total-crosses-hosts',
212
+ message: 'Contacts are shared across every site in this organization, while ' + 'attributions belong to one site. The uncredited figure therefore ' + 'includes contacts that were created on another site and could ' + 'never have been credited here.'
213
+ });
214
+ }
215
+ return {
216
+ kind,
217
+ attributed,
218
+ total,
219
+ unattributed,
220
+ // Never true while the reasons above stand. The field exists so a screen
221
+ // reads the model rather than restating its conclusion in JSX.
222
+ exact: false,
223
+ caveats
224
+ };
225
+ }
226
+ /**
227
+ * How a record names the thing it was credited to.
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.
232
+ *
233
+ * The `utm_` triple is joined in the order a marketer set it, with the parts
234
+ * that are absent left out rather than filled with a placeholder.
235
+ * `google / cpc` and `google / cpc / (none)` describe the same link, and only
236
+ * one of them invites the reader to look for a campaign called "(none)".
237
+ */ export function campaignTouchLabel(record) {
238
+ var _record_campaignId;
239
+ if (!record) return '';
240
+ if (record.channel === 'email') return String((_record_campaignId = record.campaignId) != null ? _record_campaignId : '');
241
+ const parts = [
242
+ record.source,
243
+ record.medium,
244
+ record.campaign
245
+ ].map((part)=>String(part != null ? part : '').trim()).filter(Boolean);
246
+ return parts.join(' / ');
247
+ }
248
+
249
+ //# sourceMappingURL=campaign-conversions.js.map
@@ -0,0 +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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"}