@aglyn/plugins-marketing 1.0.0-beta.218 → 1.0.0-beta.219
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/README.md +2 -0
- package/package.json +15 -15
- package/src/lib/analytics-provider.d.ts +177 -0
- package/src/lib/analytics-provider.js +370 -0
- package/src/lib/analytics-provider.js.map +1 -0
- package/src/lib/components/campaign-detail-card.js +16 -16
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-members-section.js +11 -11
- package/src/lib/components/campaign-members-section.js.map +1 -1
- package/src/lib/components/campaign-report-card.js +6 -4
- package/src/lib/components/campaign-report-card.js.map +1 -1
- package/src/lib/components/campaigns-card.js +1 -1
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/conversion-attribution.component.js +1 -1
- package/src/lib/components/conversion-attribution.component.js.map +1 -1
- package/src/lib/components/email-detail.js +12 -7
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/components/email-messages-widget.js +1 -1
- package/src/lib/components/email-messages-widget.js.map +1 -1
- package/src/lib/components/emails-list-card.js +11 -5
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +12 -7
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/components/org-experiments-card.js +1 -1
- package/src/lib/components/org-experiments-card.js.map +1 -1
- package/src/lib/components/record-route-context.d.ts +41 -0
- package/src/lib/components/record-route-context.js +63 -0
- package/src/lib/components/record-route-context.js.map +1 -0
- package/src/lib/components/use-marketing-hub-path.d.ts +18 -5
- package/src/lib/components/use-marketing-hub-path.js +36 -6
- package/src/lib/components/use-marketing-hub-path.js.map +1 -1
- package/src/lib/model/campaign-send-time.d.ts +74 -0
- package/src/lib/model/campaign-send-time.js +120 -0
- package/src/lib/model/campaign-send-time.js.map +1 -0
- package/src/lib/model/experiments.js +1 -1
- package/src/lib/model/experiments.js.map +1 -1
- package/src/lib/server/campaign-batch-plan.d.ts +73 -0
- package/src/lib/server/campaign-batch-plan.js +79 -0
- package/src/lib/server/campaign-batch-plan.js.map +1 -0
- package/src/lib/server/campaign-send.js +10 -5
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/email-events.js +7 -6
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/server/list-send-time-facts.d.ts +60 -0
- package/src/lib/server/list-send-time-facts.js +108 -0
- package/src/lib/server/list-send-time-facts.js.map +1 -0
- package/src/lib/server.js +4 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.d.ts +29 -0
- package/src/lib/tenant-emails.js +41 -0
- package/src/lib/tenant-emails.js.map +1 -0
- package/src/lib/testing/stand-in-emails-page-routes.d.ts +27 -0
- package/src/lib/testing/stand-in-emails-page-routes.js +42 -0
- package/src/lib/testing/stand-in-emails-page-routes.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +0 -58
- package/src/lib/components/use-emails-hub-path.js +0 -109
- package/src/lib/components/use-emails-hub-path.js.map +0 -1
|
@@ -15,10 +15,23 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
|
-
* The
|
|
18
|
+
* The MARKETING console's URL: under the site being read, or the
|
|
19
|
+
* organization's own Marketing hub on a page that names no site.
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* A converted record — a form submission in the Inbox, a contact in
|
|
22
|
+
* Contacts — links to the campaign it came from, and the surfaces that draw
|
|
23
|
+
* those records are handed their own hub's `basePath`, not this one's.
|
|
24
|
+
*
|
|
25
|
+
* Free, deliberately: the org slug and the subdomain are already in the URL
|
|
26
|
+
* the console is on, so this reads the route rather than resolving the host
|
|
27
|
+
* document — the two `getDoc`s that resolution costs would be paid on every
|
|
28
|
+
* open of a card, to render a link. `null` before the params resolve, so
|
|
29
|
+
* callers render plain text rather than a link to nowhere.
|
|
30
|
+
*
|
|
31
|
+
* Campaigns belong to the organization, so the org-level CRM and the org
|
|
32
|
+
* Marketing hub itself link a campaign to `/[orgSlug]/marketing/campaigns/{id}`.
|
|
23
33
|
*/
|
|
24
|
-
export
|
|
34
|
+
export declare function useMarketingHubPath(): string | null;
|
|
35
|
+
/** The organization's own Marketing hub, `/[orgSlug]/marketing`. */
|
|
36
|
+
export declare function orgMarketingHubPath(orgSlug: string): string;
|
|
37
|
+
export default useMarketingHubPath;
|
|
@@ -13,12 +13,42 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/
|
|
17
|
-
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { buildRoute, Route } from "@aglyn/aglyn";
|
|
18
|
+
import { useParams } from "next/navigation";
|
|
19
|
+
/**
|
|
20
|
+
* The MARKETING console's URL: under the site being read, or the
|
|
21
|
+
* organization's own Marketing hub on a page that names no site.
|
|
22
|
+
*
|
|
23
|
+
* A converted record — a form submission in the Inbox, a contact in
|
|
24
|
+
* Contacts — links to the campaign it came from, and the surfaces that draw
|
|
25
|
+
* those records are handed their own hub's `basePath`, not this one's.
|
|
26
|
+
*
|
|
27
|
+
* Free, deliberately: the org slug and the subdomain are already in the URL
|
|
28
|
+
* the console is on, so this reads the route rather than resolving the host
|
|
29
|
+
* document — the two `getDoc`s that resolution costs would be paid on every
|
|
30
|
+
* open of a card, to render a link. `null` before the params resolve, so
|
|
31
|
+
* callers render plain text rather than a link to nowhere.
|
|
18
32
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
* Campaigns belong to the organization, so the org-level CRM and the org
|
|
34
|
+
* Marketing hub itself link a campaign to `/[orgSlug]/marketing/campaigns/{id}`.
|
|
35
|
+
*/ export function useMarketingHubPath() {
|
|
36
|
+
const params = useParams();
|
|
37
|
+
const orgSlug = params == null ? void 0 : params.orgSlug;
|
|
38
|
+
if (!orgSlug) return null;
|
|
39
|
+
return (params == null ? void 0 : params.host) ? buildRoute(Route.HOST_PLUGIN, {
|
|
40
|
+
orgSlug,
|
|
41
|
+
host: params.host,
|
|
42
|
+
pluginSlug: MARKETING_SLUG
|
|
43
|
+
}) : orgMarketingHubPath(orgSlug);
|
|
44
|
+
}
|
|
45
|
+
/** The Marketing hub's URL slug, under a site and under the org alike. */ const MARKETING_SLUG = 'marketing';
|
|
46
|
+
/** The organization's own Marketing hub, `/[orgSlug]/marketing`. */ export function orgMarketingHubPath(orgSlug) {
|
|
47
|
+
return buildRoute(Route.ORG_PLUGIN, {
|
|
48
|
+
orgSlug,
|
|
49
|
+
pluginSlug: MARKETING_SLUG
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export default useMarketingHubPath;
|
|
23
53
|
|
|
24
54
|
//# sourceMappingURL=use-marketing-hub-path.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/use-marketing-hub-path.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 * The
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/use-marketing-hub-path.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'use client'\n\nimport { buildRoute, Route } from '@aglyn/aglyn'\nimport { useParams } from 'next/navigation'\n\n/**\n * The MARKETING console's URL: under the site being read, or the\n * organization's own Marketing hub on a page that names no site.\n *\n * A converted record — a form submission in the Inbox, a contact in\n * Contacts — links to the campaign it came from, and the surfaces that draw\n * those records are handed their own hub's `basePath`, not this one's.\n *\n * Free, deliberately: the org slug and the subdomain are already in the URL\n * the console is on, so this reads the route rather than resolving the host\n * document — the two `getDoc`s that resolution costs would be paid on every\n * open of a card, to render a link. `null` before the params resolve, so\n * callers render plain text rather than a link to nowhere.\n *\n * Campaigns belong to the organization, so the org-level CRM and the org\n * Marketing hub itself link a campaign to `/[orgSlug]/marketing/campaigns/{id}`.\n */\nexport function useMarketingHubPath(): string | null {\n const params = useParams<{ orgSlug: string; host: string }>()\n const orgSlug = params?.orgSlug\n if (!orgSlug) return null\n return params?.host\n ? buildRoute(Route.HOST_PLUGIN, {\n orgSlug,\n host: params.host,\n pluginSlug: MARKETING_SLUG,\n })\n : orgMarketingHubPath(orgSlug)\n}\n\n/** The Marketing hub's URL slug, under a site and under the org alike. */\nconst MARKETING_SLUG = 'marketing'\n\n/** The organization's own Marketing hub, `/[orgSlug]/marketing`. */\nexport function orgMarketingHubPath(orgSlug: string): string {\n return buildRoute(Route.ORG_PLUGIN, { orgSlug, pluginSlug: MARKETING_SLUG })\n}\n\nexport default useMarketingHubPath\n"],"names":["buildRoute","Route","useParams","useMarketingHubPath","params","orgSlug","host","HOST_PLUGIN","pluginSlug","MARKETING_SLUG","orgMarketingHubPath","ORG_PLUGIN"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;AAEA,SAASA,UAAU,EAAEC,KAAK,QAAQ,eAAc;AAChD,SAASC,SAAS,QAAQ,kBAAiB;AAE3C;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC;IACd,MAAMC,SAASF;IACf,MAAMG,UAAUD,0BAAAA,OAAQC,OAAO;IAC/B,IAAI,CAACA,SAAS,OAAO;IACrB,OAAOD,CAAAA,0BAAAA,OAAQE,IAAI,IACfN,WAAWC,MAAMM,WAAW,EAAE;QAC5BF;QACAC,MAAMF,OAAOE,IAAI;QACjBE,YAAYC;IACd,KACAC,oBAAoBL;AAC1B;AAEA,wEAAwE,GACxE,MAAMI,iBAAiB;AAEvB,kEAAkE,GAClE,OAAO,SAASC,oBAAoBL,OAAe;IACjD,OAAOL,WAAWC,MAAMU,UAAU,EAAE;QAAEN;QAASG,YAAYC;IAAe;AAC5E;AAEA,eAAeN,oBAAmB"}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* WHEN A LIST READS ITS MAIL: a suggested send time, taken from the sends that
|
|
19
|
+
* already went to it.
|
|
20
|
+
*
|
|
21
|
+
* A rule and not a guess. Each past send is put in the slot it went out in —
|
|
22
|
+
* its weekday and hour, in one named time zone — and the slot whose sends were
|
|
23
|
+
* opened by the largest share of the people they reached is the suggestion.
|
|
24
|
+
* The share is POOLED across a slot's sends (every unique open over every
|
|
25
|
+
* delivered message) rather than averaged, so one small send that happened to
|
|
26
|
+
* be opened by all nine of its recipients does not outrank a slot that reached
|
|
27
|
+
* thousands.
|
|
28
|
+
*
|
|
29
|
+
* Only sends that can be measured are counted: a send with no delivered count
|
|
30
|
+
* large enough to say anything is left out, and so is one whose opens exceed
|
|
31
|
+
* its deliveries, which is a record that disagrees with itself. With fewer
|
|
32
|
+
* measured sends than the minimum there is no suggestion at all — "not enough
|
|
33
|
+
* history" is an answer a composer can say, and a slot picked from one send is
|
|
34
|
+
* not.
|
|
35
|
+
*
|
|
36
|
+
* Pure, with the reads left to the caller that holds the credentials.
|
|
37
|
+
*/
|
|
38
|
+
/** One past send to the list, as the suggestion needs it. */
|
|
39
|
+
export interface CampaignSendTimeSample {
|
|
40
|
+
/** When the send went out. */
|
|
41
|
+
sentAtMs: number;
|
|
42
|
+
/** Messages the provider delivered. */
|
|
43
|
+
delivered: number;
|
|
44
|
+
/** Distinct recipients who opened it. */
|
|
45
|
+
uniqueOpens: number;
|
|
46
|
+
}
|
|
47
|
+
export interface CampaignSendTimeSuggestion {
|
|
48
|
+
/** 0 for Sunday through 6 for Saturday, in `timeZone`. */
|
|
49
|
+
weekday: number;
|
|
50
|
+
/** 0 to 23, in `timeZone`. */
|
|
51
|
+
hour: number;
|
|
52
|
+
/** The IANA zone the slot is stated in. */
|
|
53
|
+
timeZone: string;
|
|
54
|
+
/** The pooled unique-open rate of the sends in the slot, 0 to 1. */
|
|
55
|
+
openRate: number;
|
|
56
|
+
/** Measured sends that went out in the slot. */
|
|
57
|
+
sends: number;
|
|
58
|
+
/** Measured sends the slot was chosen among. */
|
|
59
|
+
measured: number;
|
|
60
|
+
}
|
|
61
|
+
/** The fewest measured sends a suggestion is taken from. */
|
|
62
|
+
export declare const CAMPAIGN_SEND_TIME_MIN_SENDS = 3;
|
|
63
|
+
/** The fewest delivered messages that make a send measurable. */
|
|
64
|
+
export declare const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20;
|
|
65
|
+
/**
|
|
66
|
+
* The slot the list's past sends were opened most in, or `null` when too few
|
|
67
|
+
* sends can be measured. An unknown time zone is read as UTC, and the answer
|
|
68
|
+
* says so.
|
|
69
|
+
*/
|
|
70
|
+
export declare function suggestCampaignSendTime(samples: ReadonlyArray<CampaignSendTimeSample>, options?: {
|
|
71
|
+
timeZone?: string;
|
|
72
|
+
}): CampaignSendTimeSuggestion | null;
|
|
73
|
+
/** The slot as a person reads it: "Tuesdays around 9 AM (UTC)". */
|
|
74
|
+
export declare function campaignSendTimeLabel(suggestion: Pick<CampaignSendTimeSuggestion, 'weekday' | 'hour' | 'timeZone'>): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { campaignRate } from "@aglyn/shared-ui-email-campaigns/model/campaign-report";
|
|
18
|
+
/** The fewest measured sends a suggestion is taken from. */ export const CAMPAIGN_SEND_TIME_MIN_SENDS = 3;
|
|
19
|
+
/** The fewest delivered messages that make a send measurable. */ export const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20;
|
|
20
|
+
const WEEKDAYS = {
|
|
21
|
+
Sun: 0,
|
|
22
|
+
Mon: 1,
|
|
23
|
+
Tue: 2,
|
|
24
|
+
Wed: 3,
|
|
25
|
+
Thu: 4,
|
|
26
|
+
Fri: 5,
|
|
27
|
+
Sat: 6
|
|
28
|
+
};
|
|
29
|
+
const WEEKDAY_NAMES = [
|
|
30
|
+
'Sunday',
|
|
31
|
+
'Monday',
|
|
32
|
+
'Tuesday',
|
|
33
|
+
'Wednesday',
|
|
34
|
+
'Thursday',
|
|
35
|
+
'Friday',
|
|
36
|
+
'Saturday'
|
|
37
|
+
];
|
|
38
|
+
function slotFormatter(timeZone) {
|
|
39
|
+
try {
|
|
40
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
41
|
+
timeZone,
|
|
42
|
+
weekday: 'short',
|
|
43
|
+
hour: 'numeric',
|
|
44
|
+
hourCycle: 'h23'
|
|
45
|
+
});
|
|
46
|
+
} catch (unused) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The weekday and hour an instant falls in, in a zone. */ function slotOf(formatter, atMs) {
|
|
51
|
+
let weekday;
|
|
52
|
+
let hour;
|
|
53
|
+
for (const part of formatter.formatToParts(new Date(atMs))){
|
|
54
|
+
if (part.type === 'weekday') weekday = WEEKDAYS[part.value];
|
|
55
|
+
if (part.type === 'hour') hour = Number(part.value) % 24;
|
|
56
|
+
}
|
|
57
|
+
return weekday === undefined || hour === undefined || !Number.isFinite(hour) ? null : {
|
|
58
|
+
weekday,
|
|
59
|
+
hour
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The slot the list's past sends were opened most in, or `null` when too few
|
|
64
|
+
* sends can be measured. An unknown time zone is read as UTC, and the answer
|
|
65
|
+
* says so.
|
|
66
|
+
*/ export function suggestCampaignSendTime(samples, options = {}) {
|
|
67
|
+
var _slotFormatter;
|
|
68
|
+
var _options_timeZone;
|
|
69
|
+
const requested = ((_options_timeZone = options.timeZone) == null ? void 0 : _options_timeZone.trim()) || 'UTC';
|
|
70
|
+
const formatter = (_slotFormatter = slotFormatter(requested)) != null ? _slotFormatter : slotFormatter('UTC');
|
|
71
|
+
const timeZone = slotFormatter(requested) ? requested : 'UTC';
|
|
72
|
+
if (!formatter) return null;
|
|
73
|
+
const slots = new Map();
|
|
74
|
+
let measured = 0;
|
|
75
|
+
for (const sample of samples){
|
|
76
|
+
var _slots_get;
|
|
77
|
+
const delivered = Number(sample.delivered);
|
|
78
|
+
const opens = Number(sample.uniqueOpens);
|
|
79
|
+
if (!Number.isFinite(sample.sentAtMs) || sample.sentAtMs <= 0) continue;
|
|
80
|
+
if (!Number.isFinite(delivered) || delivered < CAMPAIGN_SEND_TIME_MIN_DELIVERED) continue;
|
|
81
|
+
if (!Number.isFinite(opens) || opens < 0 || opens > delivered) continue;
|
|
82
|
+
const slot = slotOf(formatter, sample.sentAtMs);
|
|
83
|
+
if (!slot) continue;
|
|
84
|
+
measured += 1;
|
|
85
|
+
const key = `${slot.weekday}:${slot.hour}`;
|
|
86
|
+
const held = (_slots_get = slots.get(key)) != null ? _slots_get : _extends({}, slot, {
|
|
87
|
+
delivered: 0,
|
|
88
|
+
opens: 0,
|
|
89
|
+
sends: 0
|
|
90
|
+
});
|
|
91
|
+
held.delivered += delivered;
|
|
92
|
+
held.opens += opens;
|
|
93
|
+
held.sends += 1;
|
|
94
|
+
slots.set(key, held);
|
|
95
|
+
}
|
|
96
|
+
if (measured < CAMPAIGN_SEND_TIME_MIN_SENDS) return null;
|
|
97
|
+
let best = null;
|
|
98
|
+
for (const slot of slots.values()){
|
|
99
|
+
const rate = campaignRate(slot.opens, slot.delivered, 'delivered');
|
|
100
|
+
if (!rate) continue;
|
|
101
|
+
const candidate = {
|
|
102
|
+
weekday: slot.weekday,
|
|
103
|
+
hour: slot.hour,
|
|
104
|
+
timeZone,
|
|
105
|
+
openRate: rate.value,
|
|
106
|
+
sends: slot.sends,
|
|
107
|
+
measured
|
|
108
|
+
};
|
|
109
|
+
const better = !best || candidate.openRate > best.openRate || candidate.openRate === best.openRate && (candidate.sends > best.sends || candidate.sends === best.sends && candidate.weekday * 24 + candidate.hour < best.weekday * 24 + best.hour);
|
|
110
|
+
if (better) best = candidate;
|
|
111
|
+
}
|
|
112
|
+
return best;
|
|
113
|
+
}
|
|
114
|
+
/** The slot as a person reads it: "Tuesdays around 9 AM (UTC)". */ export function campaignSendTimeLabel(suggestion) {
|
|
115
|
+
const hour12 = suggestion.hour % 12 === 0 ? 12 : suggestion.hour % 12;
|
|
116
|
+
const meridiem = suggestion.hour < 12 ? 'AM' : 'PM';
|
|
117
|
+
return `${WEEKDAY_NAMES[suggestion.weekday]}s around ${hour12} ${meridiem} (${suggestion.timeZone})`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
//# sourceMappingURL=campaign-send-time.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-send-time.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\nimport { campaignRate } from '@aglyn/shared-ui-email-campaigns/model/campaign-report'\n\n/**\n * WHEN A LIST READS ITS MAIL: a suggested send time, taken from the sends that\n * already went to it.\n *\n * A rule and not a guess. Each past send is put in the slot it went out in —\n * its weekday and hour, in one named time zone — and the slot whose sends were\n * opened by the largest share of the people they reached is the suggestion.\n * The share is POOLED across a slot's sends (every unique open over every\n * delivered message) rather than averaged, so one small send that happened to\n * be opened by all nine of its recipients does not outrank a slot that reached\n * thousands.\n *\n * Only sends that can be measured are counted: a send with no delivered count\n * large enough to say anything is left out, and so is one whose opens exceed\n * its deliveries, which is a record that disagrees with itself. With fewer\n * measured sends than the minimum there is no suggestion at all — \"not enough\n * history\" is an answer a composer can say, and a slot picked from one send is\n * not.\n *\n * Pure, with the reads left to the caller that holds the credentials.\n */\n\n/** One past send to the list, as the suggestion needs it. */\nexport interface CampaignSendTimeSample {\n /** When the send went out. */\n sentAtMs: number\n /** Messages the provider delivered. */\n delivered: number\n /** Distinct recipients who opened it. */\n uniqueOpens: number\n}\n\nexport interface CampaignSendTimeSuggestion {\n /** 0 for Sunday through 6 for Saturday, in `timeZone`. */\n weekday: number\n /** 0 to 23, in `timeZone`. */\n hour: number\n /** The IANA zone the slot is stated in. */\n timeZone: string\n /** The pooled unique-open rate of the sends in the slot, 0 to 1. */\n openRate: number\n /** Measured sends that went out in the slot. */\n sends: number\n /** Measured sends the slot was chosen among. */\n measured: number\n}\n\n/** The fewest measured sends a suggestion is taken from. */\nexport const CAMPAIGN_SEND_TIME_MIN_SENDS = 3\n\n/** The fewest delivered messages that make a send measurable. */\nexport const CAMPAIGN_SEND_TIME_MIN_DELIVERED = 20\n\nconst WEEKDAYS: Record<string, number> = {\n Sun: 0,\n Mon: 1,\n Tue: 2,\n Wed: 3,\n Thu: 4,\n Fri: 5,\n Sat: 6,\n}\n\nconst WEEKDAY_NAMES = [\n 'Sunday',\n 'Monday',\n 'Tuesday',\n 'Wednesday',\n 'Thursday',\n 'Friday',\n 'Saturday',\n]\n\nfunction slotFormatter(timeZone: string): Intl.DateTimeFormat | null {\n try {\n return new Intl.DateTimeFormat('en-US', {\n timeZone,\n weekday: 'short',\n hour: 'numeric',\n hourCycle: 'h23',\n })\n } catch {\n return null\n }\n}\n\n/** The weekday and hour an instant falls in, in a zone. */\nfunction slotOf(\n formatter: Intl.DateTimeFormat,\n atMs: number,\n): { weekday: number; hour: number } | null {\n let weekday: number | undefined\n let hour: number | undefined\n for (const part of formatter.formatToParts(new Date(atMs))) {\n if (part.type === 'weekday') weekday = WEEKDAYS[part.value]\n if (part.type === 'hour') hour = Number(part.value) % 24\n }\n return weekday === undefined || hour === undefined || !Number.isFinite(hour)\n ? null\n : { weekday, hour }\n}\n\n/**\n * The slot the list's past sends were opened most in, or `null` when too few\n * sends can be measured. An unknown time zone is read as UTC, and the answer\n * says so.\n */\nexport function suggestCampaignSendTime(\n samples: ReadonlyArray<CampaignSendTimeSample>,\n options: { timeZone?: string } = {},\n): CampaignSendTimeSuggestion | null {\n const requested = options.timeZone?.trim() || 'UTC'\n const formatter = slotFormatter(requested) ?? slotFormatter('UTC')\n const timeZone = slotFormatter(requested) ? requested : 'UTC'\n if (!formatter) return null\n const slots = new Map<string, { weekday: number; hour: number; delivered: number; opens: number; sends: number }>()\n let measured = 0\n for (const sample of samples) {\n const delivered = Number(sample.delivered)\n const opens = Number(sample.uniqueOpens)\n if (!Number.isFinite(sample.sentAtMs) || sample.sentAtMs <= 0) continue\n if (!Number.isFinite(delivered) || delivered < CAMPAIGN_SEND_TIME_MIN_DELIVERED) continue\n if (!Number.isFinite(opens) || opens < 0 || opens > delivered) continue\n const slot = slotOf(formatter, sample.sentAtMs)\n if (!slot) continue\n measured += 1\n const key = `${slot.weekday}:${slot.hour}`\n const held = slots.get(key) ?? { ...slot, delivered: 0, opens: 0, sends: 0 }\n held.delivered += delivered\n held.opens += opens\n held.sends += 1\n slots.set(key, held)\n }\n if (measured < CAMPAIGN_SEND_TIME_MIN_SENDS) return null\n let best: CampaignSendTimeSuggestion | null = null\n for (const slot of slots.values()) {\n const rate = campaignRate(slot.opens, slot.delivered, 'delivered')\n if (!rate) continue\n const candidate: CampaignSendTimeSuggestion = {\n weekday: slot.weekday,\n hour: slot.hour,\n timeZone,\n openRate: rate.value,\n sends: slot.sends,\n measured,\n }\n const better =\n !best ||\n candidate.openRate > best.openRate ||\n (candidate.openRate === best.openRate &&\n (candidate.sends > best.sends ||\n (candidate.sends === best.sends &&\n candidate.weekday * 24 + candidate.hour < best.weekday * 24 + best.hour)))\n if (better) best = candidate\n }\n return best\n}\n\n/** The slot as a person reads it: \"Tuesdays around 9 AM (UTC)\". */\nexport function campaignSendTimeLabel(\n suggestion: Pick<CampaignSendTimeSuggestion, 'weekday' | 'hour' | 'timeZone'>,\n): string {\n const hour12 = suggestion.hour % 12 === 0 ? 12 : suggestion.hour % 12\n const meridiem = suggestion.hour < 12 ? 'AM' : 'PM'\n return `${WEEKDAY_NAMES[suggestion.weekday]}s around ${hour12} ${meridiem} (${suggestion.timeZone})`\n}\n"],"names":["campaignRate","CAMPAIGN_SEND_TIME_MIN_SENDS","CAMPAIGN_SEND_TIME_MIN_DELIVERED","WEEKDAYS","Sun","Mon","Tue","Wed","Thu","Fri","Sat","WEEKDAY_NAMES","slotFormatter","timeZone","Intl","DateTimeFormat","weekday","hour","hourCycle","slotOf","formatter","atMs","part","formatToParts","Date","type","value","Number","undefined","isFinite","suggestCampaignSendTime","samples","options","requested","trim","slots","Map","measured","sample","delivered","opens","uniqueOpens","sentAtMs","slot","key","held","get","sends","set","best","values","rate","candidate","openRate","better","campaignSendTimeLabel","suggestion","hour12","meridiem"],"mappings":"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"}
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
return 'Section experiments need the canvas element id';
|
|
82
82
|
}
|
|
83
83
|
if ((experiment.target === 'screen' || experiment.target === 'section') && !((_experiment_screenId = experiment.screenId) == null ? void 0 : _experiment_screenId.trim())) {
|
|
84
|
-
return 'Pick the
|
|
84
|
+
return 'Pick the page under test';
|
|
85
85
|
}
|
|
86
86
|
if (experiment.autoWinner) {
|
|
87
87
|
const { minExposures, confidence } = experiment.autoWinner;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/experiments.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 * A/B experiments (AGL-252): docs at `hosts/{hostId}/experiments/{id}`.\n * Assignment is deterministic — hash(visitorId + experimentId) buckets a\n * visitor into a weighted variant, so the same visitor always sees the\n * same variant with no server state. Exposures/conversions accumulate on\n * per-variant stats counters via the track API. Business tier\n * (`abTesting` flag).\n */\nexport type ExperimentStatus = 'draft' | 'running' | 'paused' | 'done'\n\n/** What the experiment varies. */\nexport type ExperimentTarget = 'screen' | 'section' | 'email'\n\nexport interface ExperimentVariant {\n id: string\n name?: string\n /** Relative traffic weight; defaults to 1. */\n weight?: number\n /** Screen experiments: the screen version this variant serves. */\n versionId?: string\n /** Email experiments (AGL-255): subject/body overrides. */\n subject?: string\n body?: string\n}\n\nexport interface HostExperiment {\n name: string\n status: ExperimentStatus\n target: ExperimentTarget\n /** Screen/section experiments: the screen under test. */\n screenId?: string\n /** Section experiments: canvas node id the variants swap. */\n nodeId?: string\n variants: ExperimentVariant[]\n /** Conversion goal: a host/site event, optionally filtered. */\n goal?: { event: string; filter?: string }\n /** Set when finished; the renderer then serves only the winner. */\n winnerVariantId?: string\n /**\n * Epoch millis (AGL-273): past this a running experiment stops serving\n * variants (visitors get the default) until someone declares a winner.\n */\n endAtMs?: number\n /**\n * Opt-in auto-completion (AGL-273): once every variant has\n * `minExposures` and a challenger clears `confidence` (0..1) against\n * the control, the track pipeline promotes it and finishes the\n * experiment. Control wins when every challenger is confidently worse.\n */\n autoWinner?: { minExposures: number; confidence: number }\n /** Set by the pipeline when `autoWinner` decided the experiment. */\n autoCompleted?: boolean\n}\n\nexport const EXPERIMENT_MAX_VARIANTS = 4\n\n/** FNV-1a over the visitor+experiment key — stable, dependency-free. */\nfunction bucketHash(value: string): number {\n let hash = 0x811c9dc5\n for (let index = 0; index < value.length; index += 1) {\n hash ^= value.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return hash >>> 0\n}\n\n/**\n * Deterministic weighted assignment: the same visitor always lands in the\n * same variant of an experiment. Finished experiments return the winner;\n * non-running experiments without a winner return null (serve default).\n */\nexport function assignExperimentVariant(\n experiment: Pick<\n HostExperiment,\n 'status' | 'variants' | 'winnerVariantId' | 'endAtMs'\n > & {\n $id?: string\n },\n experimentId: string,\n visitorId: string,\n nowMs: number = Date.now(),\n): ExperimentVariant | null {\n const variants = (experiment.variants ?? []).slice(\n 0,\n EXPERIMENT_MAX_VARIANTS,\n )\n if (!variants.length) return null\n if (experiment.winnerVariantId) {\n return (\n variants.find((variant) => variant.id === experiment.winnerVariantId) ??\n null\n )\n }\n if (experiment.status !== 'running') return null\n // Past the end date (AGL-273) visitors get the default until a winner\n // is declared — stats stop accruing rather than drifting post-test.\n if (experiment.endAtMs && nowMs > experiment.endAtMs) return null\n const totalWeight = variants.reduce(\n (sum, variant) => sum + Math.max(0, variant.weight ?? 1),\n 0,\n )\n if (totalWeight <= 0) return null\n const bucket = bucketHash(`${visitorId}:${experimentId}`) % totalWeight\n let cursor = 0\n for (const variant of variants) {\n cursor += Math.max(0, variant.weight ?? 1)\n if (bucket < cursor) return variant\n }\n return variants[variants.length - 1]\n}\n\n/** Validates an experiment doc shape; human-readable error or null. */\nexport function validateExperiment(\n experiment: HostExperiment,\n): string | null {\n if (!experiment.name?.trim()) return 'Name the experiment'\n if (!['screen', 'section', 'email'].includes(experiment.target)) {\n return 'Pick what the experiment tests'\n }\n const variants = experiment.variants ?? []\n if (variants.length < 2) return 'Add at least two variants'\n if (variants.length > EXPERIMENT_MAX_VARIANTS) {\n return `Experiments are capped at ${EXPERIMENT_MAX_VARIANTS} variants`\n }\n const ids = new Set(variants.map((variant) => variant.id))\n if (ids.size !== variants.length) return 'Variant ids must be unique'\n if (experiment.target === 'section' && !experiment.nodeId?.trim()) {\n return 'Section experiments need the canvas element id'\n }\n if (\n (experiment.target === 'screen' || experiment.target === 'section') &&\n !experiment.screenId?.trim()\n ) {\n return 'Pick the screen under test'\n }\n if (experiment.autoWinner) {\n const { minExposures, confidence } = experiment.autoWinner\n if (!Number.isFinite(minExposures) || minExposures < 1) {\n return 'Auto-winner needs a minimum exposure count of at least 1'\n }\n if (!Number.isFinite(confidence) || confidence <= 0.5 || confidence >= 1) {\n return 'Auto-winner confidence must be between 0.5 and 1'\n }\n }\n return null\n}\n\n/** Naive conversion-rate summary the results table renders. */\nexport function summarizeVariantStats(stats: {\n exposures?: number\n conversions?: number\n}): { exposures: number; conversions: number; rate: number } {\n const exposures = Math.max(0, stats.exposures ?? 0)\n const conversions = Math.max(0, stats.conversions ?? 0)\n return {\n exposures,\n conversions,\n rate: exposures > 0 ? conversions / exposures : 0,\n }\n}\n\nexport interface VariantComparison {\n /** Relative conversion-rate lift vs the control; null when unmeasurable. */\n lift: number | null\n /**\n * One-sided confidence (0..1) that the challenger genuinely beats the\n * control — a two-proportion z-test through the normal CDF. Null until\n * both variants have exposures and at least one conversion exists.\n */\n confidence: number | null\n}\n\n/** Abramowitz–Stegun erf approximation (max error ~1.5e-7). */\nfunction erf(x: number): number {\n const sign = x < 0 ? -1 : 1\n const t = 1 / (1 + 0.3275911 * Math.abs(x))\n const y =\n 1 -\n (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t -\n 0.284496736) *\n t +\n 0.254829592) *\n t *\n Math.exp(-x * x)\n return sign * y\n}\n\n/** Standard normal CDF. */\nfunction normalCdf(z: number): number {\n return 0.5 * (1 + erf(z / Math.SQRT2))\n}\n\n/**\n * Compares a challenger variant against the control (AGL-265): lift =\n * relative rate change; confidence = P(challenger > control) from a\n * pooled two-proportion z-test. Small samples return null confidence\n * rather than a misleading number.\n */\nexport function compareVariants(\n control: { exposures?: number; conversions?: number },\n challenger: { exposures?: number; conversions?: number },\n): VariantComparison {\n const a = summarizeVariantStats(control)\n const b = summarizeVariantStats(challenger)\n const lift = a.rate > 0 ? (b.rate - a.rate) / a.rate : null\n if (\n a.exposures < 1 ||\n b.exposures < 1 ||\n a.conversions + b.conversions < 1\n ) {\n return { lift, confidence: null }\n }\n const pooled =\n (a.conversions + b.conversions) / (a.exposures + b.exposures)\n const standardError = Math.sqrt(\n pooled * (1 - pooled) * (1 / a.exposures + 1 / b.exposures),\n )\n if (!Number.isFinite(standardError) || standardError === 0) {\n return { lift, confidence: null }\n }\n const z = (b.rate - a.rate) / standardError\n return { lift, confidence: normalCdf(z) }\n}\n\n/** Per-variant counters, keyed by variant id, as `experiments/{id}/stats` holds them. */\nexport type ExperimentStatsByVariant = Record<\n string,\n { exposures?: number; conversions?: number } | undefined\n>\n\n/** One variant's line in the results table. */\nexport interface ExperimentResultRow {\n variant: ExperimentVariant\n summary: { exposures: number; conversions: number; rate: number }\n /**\n * Lift and confidence against the control — the FIRST variant, which is\n * compared against nothing and so carries `null` here.\n */\n comparison: VariantComparison | null\n /** It has exposures and no variant converts at a higher rate. Ties all lead. */\n leader: boolean\n /** The variant the experiment finished on. */\n winner: boolean\n}\n\n/**\n * The results table's rows, in variant order: each variant's figures, its\n * comparison with the control (AGL-265), and whether it leads or won.\n */\nexport function experimentResultRows(\n experiment: Pick<HostExperiment, 'variants' | 'winnerVariantId'>,\n stats: ExperimentStatsByVariant,\n): ExperimentResultRow[] {\n const variants = experiment.variants ?? []\n const summaries = variants.map((variant) =>\n summarizeVariantStats(stats[variant.id] ?? {}),\n )\n const control = variants[0]\n return variants.map((variant, index) => {\n const summary = summaries[index]\n return {\n variant,\n summary,\n comparison:\n index > 0 && control\n ? compareVariants(stats[control.id] ?? {}, stats[variant.id] ?? {})\n : null,\n leader:\n summary.exposures > 0 &&\n summaries.every((other) => summary.rate >= other.rate),\n winner: experiment.winnerVariantId === variant.id,\n }\n })\n}\n\n/**\n * A comparison as the results table prints it: `+12% · 97% conf.`, a dash\n * for a lift the control's zero rate cannot express, `needs data` until the\n * z-test has something to test, and `control` for the variant everything is\n * measured against.\n */\nexport function describeVariantComparison(\n comparison: VariantComparison | null,\n): string {\n if (!comparison) return 'control'\n const lift =\n comparison.lift != null\n ? `${comparison.lift >= 0 ? '+' : ''}${(comparison.lift * 100).toFixed(0)}%`\n : '—'\n const confidence =\n comparison.confidence != null\n ? ` · ${(comparison.confidence * 100).toFixed(0)}% conf.`\n : ' · needs data'\n return `${lift}${confidence}`\n}\n\n/**\n * Auto-winner evaluation (AGL-273): decides a running experiment from\n * per-variant stats once thresholds are met. The first variant is the\n * control. Undecidable states return null: any arm still under\n * `minExposures`, or no challenger confidently better while some remain\n * in the uncertain middle. Control wins only when every challenger is\n * confidently worse (its win probability is below `1 - confidence`).\n */\nexport function evaluateAutoWinner(\n experiment: Pick<HostExperiment, 'status' | 'variants' | 'autoWinner'>,\n statsByVariant: Record<\n string,\n { exposures?: number; conversions?: number } | undefined\n >,\n): { variantId: string; confidence: number } | null {\n const config = experiment.autoWinner\n if (!config || experiment.status !== 'running') return null\n const variants = (experiment.variants ?? []).slice(\n 0,\n EXPERIMENT_MAX_VARIANTS,\n )\n if (variants.length < 2) return null\n const controlStats = statsByVariant[variants[0].id] ?? {}\n if ((controlStats.exposures ?? 0) < config.minExposures) return null\n let best: { variantId: string; confidence: number } | null = null\n let everyLoses = true\n for (const challenger of variants.slice(1)) {\n const challengerStats = statsByVariant[challenger.id] ?? {}\n if ((challengerStats.exposures ?? 0) < config.minExposures) return null\n const { confidence } = compareVariants(controlStats, challengerStats)\n if (confidence === null) return null\n if (\n confidence >= config.confidence &&\n (!best || confidence > best.confidence)\n ) {\n best = { variantId: challenger.id, confidence }\n }\n if (confidence > 1 - config.confidence) everyLoses = false\n }\n if (best) return best\n if (everyLoses) {\n return { variantId: variants[0].id, confidence: config.confidence }\n }\n return null\n}\n"],"names":["EXPERIMENT_MAX_VARIANTS","bucketHash","value","hash","index","length","charCodeAt","Math","imul","assignExperimentVariant","experiment","experimentId","visitorId","nowMs","Date","now","variants","slice","winnerVariantId","find","variant","id","status","endAtMs","totalWeight","reduce","sum","max","weight","bucket","cursor","validateExperiment","name","trim","includes","target","ids","Set","map","size","nodeId","screenId","autoWinner","minExposures","confidence","Number","isFinite","summarizeVariantStats","stats","exposures","conversions","rate","erf","x","sign","t","abs","y","exp","normalCdf","z","SQRT2","compareVariants","control","challenger","a","b","lift","pooled","standardError","sqrt","experimentResultRows","summaries","summary","comparison","leader","every","other","winner","describeVariantComparison","toFixed","evaluateAutoWinner","statsByVariant","controlStats","config","best","everyLoses","challengerStats","variantId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GA+CD,OAAO,MAAMA,0BAA0B,EAAC;AAExC,sEAAsE,GACtE,SAASC,WAAWC,KAAa;IAC/B,IAAIC,OAAO;IACX,IAAK,IAAIC,QAAQ,GAAGA,QAAQF,MAAMG,MAAM,EAAED,SAAS,EAAG;QACpDD,QAAQD,MAAMI,UAAU,CAACF;QACzBD,OAAOI,KAAKC,IAAI,CAACL,MAAM;IACzB;IACA,OAAOA,SAAS;AAClB;AAEA;;;;CAIC,GACD,OAAO,SAASM,wBACdC,UAKC,EACDC,YAAoB,EACpBC,SAAiB,EACjBC,QAAgBC,KAAKC,GAAG,EAAE;QAERL;IAAlB,MAAMM,WAAW,EAACN,uBAAAA,WAAWM,QAAQ,YAAnBN,uBAAuB,EAAE,EAAEO,KAAK,CAChD,GACAjB;IAEF,IAAI,CAACgB,SAASX,MAAM,EAAE,OAAO;IAC7B,IAAIK,WAAWQ,eAAe,EAAE;YAE5BF;QADF,QACEA,iBAAAA,SAASG,IAAI,CAAC,CAACC,UAAYA,QAAQC,EAAE,KAAKX,WAAWQ,eAAe,aAApEF,iBACA;IAEJ;IACA,IAAIN,WAAWY,MAAM,KAAK,WAAW,OAAO;IAC5C,sEAAsE;IACtE,oEAAoE;IACpE,IAAIZ,WAAWa,OAAO,IAAIV,QAAQH,WAAWa,OAAO,EAAE,OAAO;IAC7D,MAAMC,cAAcR,SAASS,MAAM,CACjC,CAACC,KAAKN;YAA8BA;eAAlBM,MAAMnB,KAAKoB,GAAG,CAAC,IAAGP,kBAAAA,QAAQQ,MAAM,YAAdR,kBAAkB;OACtD;IAEF,IAAII,eAAe,GAAG,OAAO;IAC7B,MAAMK,SAAS5B,WAAW,GAAGW,UAAU,CAAC,EAAED,cAAc,IAAIa;IAC5D,IAAIM,SAAS;IACb,KAAK,MAAMV,WAAWJ,SAAU;YACRI;QAAtBU,UAAUvB,KAAKoB,GAAG,CAAC,IAAGP,kBAAAA,QAAQQ,MAAM,YAAdR,kBAAkB;QACxC,IAAIS,SAASC,QAAQ,OAAOV;IAC9B;IACA,OAAOJ,QAAQ,CAACA,SAASX,MAAM,GAAG,EAAE;AACtC;AAEA,qEAAqE,GACrE,OAAO,SAAS0B,mBACdrB,UAA0B;QAMTA;QAJZA,kBAWmCA,oBAKrCA;IAhBH,IAAI,GAACA,mBAAAA,WAAWsB,IAAI,qBAAftB,iBAAiBuB,IAAI,KAAI,OAAO;IACrC,IAAI,CAAC;QAAC;QAAU;QAAW;KAAQ,CAACC,QAAQ,CAACxB,WAAWyB,MAAM,GAAG;QAC/D,OAAO;IACT;IACA,MAAMnB,YAAWN,uBAAAA,WAAWM,QAAQ,YAAnBN,uBAAuB,EAAE;IAC1C,IAAIM,SAASX,MAAM,GAAG,GAAG,OAAO;IAChC,IAAIW,SAASX,MAAM,GAAGL,yBAAyB;QAC7C,OAAO,CAAC,0BAA0B,EAAEA,wBAAwB,SAAS,CAAC;IACxE;IACA,MAAMoC,MAAM,IAAIC,IAAIrB,SAASsB,GAAG,CAAC,CAAClB,UAAYA,QAAQC,EAAE;IACxD,IAAIe,IAAIG,IAAI,KAAKvB,SAASX,MAAM,EAAE,OAAO;IACzC,IAAIK,WAAWyB,MAAM,KAAK,aAAa,GAACzB,qBAAAA,WAAW8B,MAAM,qBAAjB9B,mBAAmBuB,IAAI,KAAI;QACjE,OAAO;IACT;IACA,IACE,AAACvB,CAAAA,WAAWyB,MAAM,KAAK,YAAYzB,WAAWyB,MAAM,KAAK,SAAQ,KACjE,GAACzB,uBAAAA,WAAW+B,QAAQ,qBAAnB/B,qBAAqBuB,IAAI,KAC1B;QACA,OAAO;IACT;IACA,IAAIvB,WAAWgC,UAAU,EAAE;QACzB,MAAM,EAAEC,YAAY,EAAEC,UAAU,EAAE,GAAGlC,WAAWgC,UAAU;QAC1D,IAAI,CAACG,OAAOC,QAAQ,CAACH,iBAAiBA,eAAe,GAAG;YACtD,OAAO;QACT;QACA,IAAI,CAACE,OAAOC,QAAQ,CAACF,eAAeA,cAAc,OAAOA,cAAc,GAAG;YACxE,OAAO;QACT;IACF;IACA,OAAO;AACT;AAEA,6DAA6D,GAC7D,OAAO,SAASG,sBAAsBC,KAGrC;QAC+BA,kBACEA;IADhC,MAAMC,YAAY1C,KAAKoB,GAAG,CAAC,IAAGqB,mBAAAA,MAAMC,SAAS,YAAfD,mBAAmB;IACjD,MAAME,cAAc3C,KAAKoB,GAAG,CAAC,IAAGqB,qBAAAA,MAAME,WAAW,YAAjBF,qBAAqB;IACrD,OAAO;QACLC;QACAC;QACAC,MAAMF,YAAY,IAAIC,cAAcD,YAAY;IAClD;AACF;AAaA,6DAA6D,GAC7D,SAASG,IAAIC,CAAS;IACpB,MAAMC,OAAOD,IAAI,IAAI,CAAC,IAAI;IAC1B,MAAME,IAAI,IAAK,CAAA,IAAI,YAAYhD,KAAKiD,GAAG,CAACH,EAAC;IACzC,MAAMI,IACJ,IACA,AAAC,CAAA,AAAC,CAAA,AAAC,CAAA,AAAE,CAAA,cAAcF,IAAI,WAAU,IAAKA,IAAK,WAAU,IAAKA,IACxD,WAAU,IACVA,IACA,WAAU,IACVA,IACAhD,KAAKmD,GAAG,CAAC,CAACL,IAAIA;IAClB,OAAOC,OAAOG;AAChB;AAEA,yBAAyB,GACzB,SAASE,UAAUC,CAAS;IAC1B,OAAO,MAAO,CAAA,IAAIR,IAAIQ,IAAIrD,KAAKsD,KAAK,CAAA;AACtC;AAEA;;;;;CAKC,GACD,OAAO,SAASC,gBACdC,OAAqD,EACrDC,UAAwD;IAExD,MAAMC,IAAIlB,sBAAsBgB;IAChC,MAAMG,IAAInB,sBAAsBiB;IAChC,MAAMG,OAAOF,EAAEd,IAAI,GAAG,IAAI,AAACe,CAAAA,EAAEf,IAAI,GAAGc,EAAEd,IAAI,AAAD,IAAKc,EAAEd,IAAI,GAAG;IACvD,IACEc,EAAEhB,SAAS,GAAG,KACdiB,EAAEjB,SAAS,GAAG,KACdgB,EAAEf,WAAW,GAAGgB,EAAEhB,WAAW,GAAG,GAChC;QACA,OAAO;YAAEiB;YAAMvB,YAAY;QAAK;IAClC;IACA,MAAMwB,SACJ,AAACH,CAAAA,EAAEf,WAAW,GAAGgB,EAAEhB,WAAW,AAAD,IAAMe,CAAAA,EAAEhB,SAAS,GAAGiB,EAAEjB,SAAS,AAAD;IAC7D,MAAMoB,gBAAgB9D,KAAK+D,IAAI,CAC7BF,SAAU,CAAA,IAAIA,MAAK,IAAM,CAAA,IAAIH,EAAEhB,SAAS,GAAG,IAAIiB,EAAEjB,SAAS,AAAD;IAE3D,IAAI,CAACJ,OAAOC,QAAQ,CAACuB,kBAAkBA,kBAAkB,GAAG;QAC1D,OAAO;YAAEF;YAAMvB,YAAY;QAAK;IAClC;IACA,MAAMgB,IAAI,AAACM,CAAAA,EAAEf,IAAI,GAAGc,EAAEd,IAAI,AAAD,IAAKkB;IAC9B,OAAO;QAAEF;QAAMvB,YAAYe,UAAUC;IAAG;AAC1C;AAuBA;;;CAGC,GACD,OAAO,SAASW,qBACd7D,UAAgE,EAChEsC,KAA+B;QAEdtC;IAAjB,MAAMM,YAAWN,uBAAAA,WAAWM,QAAQ,YAAnBN,uBAAuB,EAAE;IAC1C,MAAM8D,YAAYxD,SAASsB,GAAG,CAAC,CAAClB;YACR4B;eAAtBD,uBAAsBC,oBAAAA,KAAK,CAAC5B,QAAQC,EAAE,CAAC,YAAjB2B,oBAAqB,CAAC;;IAE9C,MAAMe,UAAU/C,QAAQ,CAAC,EAAE;IAC3B,OAAOA,SAASsB,GAAG,CAAC,CAAClB,SAAShB;YAOJ4C,mBAAyBA;QANjD,MAAMyB,UAAUD,SAAS,CAACpE,MAAM;QAChC,OAAO;YACLgB;YACAqD;YACAC,YACEtE,QAAQ,KAAK2D,UACTD,iBAAgBd,oBAAAA,KAAK,CAACe,QAAQ1C,EAAE,CAAC,YAAjB2B,oBAAqB,CAAC,IAAGA,oBAAAA,KAAK,CAAC5B,QAAQC,EAAE,CAAC,YAAjB2B,oBAAqB,CAAC,KAC/D;YACN2B,QACEF,QAAQxB,SAAS,GAAG,KACpBuB,UAAUI,KAAK,CAAC,CAACC,QAAUJ,QAAQtB,IAAI,IAAI0B,MAAM1B,IAAI;YACvD2B,QAAQpE,WAAWQ,eAAe,KAAKE,QAAQC,EAAE;QACnD;IACF;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAAS0D,0BACdL,UAAoC;IAEpC,IAAI,CAACA,YAAY,OAAO;IACxB,MAAMP,OACJO,WAAWP,IAAI,IAAI,OACf,GAAGO,WAAWP,IAAI,IAAI,IAAI,MAAM,KAAK,AAACO,CAAAA,WAAWP,IAAI,GAAG,GAAE,EAAGa,OAAO,CAAC,GAAG,CAAC,CAAC,GAC1E;IACN,MAAMpC,aACJ8B,WAAW9B,UAAU,IAAI,OACrB,CAAC,GAAG,EAAE,AAAC8B,CAAAA,WAAW9B,UAAU,GAAG,GAAE,EAAGoC,OAAO,CAAC,GAAG,OAAO,CAAC,GACvD;IACN,OAAO,GAAGb,OAAOvB,YAAY;AAC/B;AAEA;;;;;;;CAOC,GACD,OAAO,SAASqC,mBACdvE,UAAsE,EACtEwE,cAGC;QAIiBxE,sBAKGwE,8BAChBC;IARL,MAAMC,SAAS1E,WAAWgC,UAAU;IACpC,IAAI,CAAC0C,UAAU1E,WAAWY,MAAM,KAAK,WAAW,OAAO;IACvD,MAAMN,WAAW,EAACN,uBAAAA,WAAWM,QAAQ,YAAnBN,uBAAuB,EAAE,EAAEO,KAAK,CAChD,GACAjB;IAEF,IAAIgB,SAASX,MAAM,GAAG,GAAG,OAAO;IAChC,MAAM8E,gBAAeD,+BAAAA,cAAc,CAAClE,QAAQ,CAAC,EAAE,CAACK,EAAE,CAAC,YAA9B6D,+BAAkC,CAAC;IACxD,IAAI,EAACC,0BAAAA,aAAalC,SAAS,YAAtBkC,0BAA0B,KAAKC,OAAOzC,YAAY,EAAE,OAAO;IAChE,IAAI0C,OAAyD;IAC7D,IAAIC,aAAa;IACjB,KAAK,MAAMtB,cAAchD,SAASC,KAAK,CAAC,GAAI;YAClBiE,+BACnBK;QADL,MAAMA,mBAAkBL,gCAAAA,cAAc,CAAClB,WAAW3C,EAAE,CAAC,YAA7B6D,gCAAiC,CAAC;QAC1D,IAAI,EAACK,6BAAAA,gBAAgBtC,SAAS,YAAzBsC,6BAA6B,KAAKH,OAAOzC,YAAY,EAAE,OAAO;QACnE,MAAM,EAAEC,UAAU,EAAE,GAAGkB,gBAAgBqB,cAAcI;QACrD,IAAI3C,eAAe,MAAM,OAAO;QAChC,IACEA,cAAcwC,OAAOxC,UAAU,IAC9B,CAAA,CAACyC,QAAQzC,aAAayC,KAAKzC,UAAU,AAAD,GACrC;YACAyC,OAAO;gBAAEG,WAAWxB,WAAW3C,EAAE;gBAAEuB;YAAW;QAChD;QACA,IAAIA,aAAa,IAAIwC,OAAOxC,UAAU,EAAE0C,aAAa;IACvD;IACA,IAAID,MAAM,OAAOA;IACjB,IAAIC,YAAY;QACd,OAAO;YAAEE,WAAWxE,QAAQ,CAAC,EAAE,CAACK,EAAE;YAAEuB,YAAYwC,OAAOxC,UAAU;QAAC;IACpE;IACA,OAAO;AACT"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/experiments.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 * A/B experiments (AGL-252): docs at `hosts/{hostId}/experiments/{id}`.\n * Assignment is deterministic — hash(visitorId + experimentId) buckets a\n * visitor into a weighted variant, so the same visitor always sees the\n * same variant with no server state. Exposures/conversions accumulate on\n * per-variant stats counters via the track API. Business tier\n * (`abTesting` flag).\n */\nexport type ExperimentStatus = 'draft' | 'running' | 'paused' | 'done'\n\n/** What the experiment varies. */\nexport type ExperimentTarget = 'screen' | 'section' | 'email'\n\nexport interface ExperimentVariant {\n id: string\n name?: string\n /** Relative traffic weight; defaults to 1. */\n weight?: number\n /** Screen experiments: the screen version this variant serves. */\n versionId?: string\n /** Email experiments (AGL-255): subject/body overrides. */\n subject?: string\n body?: string\n}\n\nexport interface HostExperiment {\n name: string\n status: ExperimentStatus\n target: ExperimentTarget\n /** Screen/section experiments: the screen under test. */\n screenId?: string\n /** Section experiments: canvas node id the variants swap. */\n nodeId?: string\n variants: ExperimentVariant[]\n /** Conversion goal: a host/site event, optionally filtered. */\n goal?: { event: string; filter?: string }\n /** Set when finished; the renderer then serves only the winner. */\n winnerVariantId?: string\n /**\n * Epoch millis (AGL-273): past this a running experiment stops serving\n * variants (visitors get the default) until someone declares a winner.\n */\n endAtMs?: number\n /**\n * Opt-in auto-completion (AGL-273): once every variant has\n * `minExposures` and a challenger clears `confidence` (0..1) against\n * the control, the track pipeline promotes it and finishes the\n * experiment. Control wins when every challenger is confidently worse.\n */\n autoWinner?: { minExposures: number; confidence: number }\n /** Set by the pipeline when `autoWinner` decided the experiment. */\n autoCompleted?: boolean\n}\n\nexport const EXPERIMENT_MAX_VARIANTS = 4\n\n/** FNV-1a over the visitor+experiment key — stable, dependency-free. */\nfunction bucketHash(value: string): number {\n let hash = 0x811c9dc5\n for (let index = 0; index < value.length; index += 1) {\n hash ^= value.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return hash >>> 0\n}\n\n/**\n * Deterministic weighted assignment: the same visitor always lands in the\n * same variant of an experiment. Finished experiments return the winner;\n * non-running experiments without a winner return null (serve default).\n */\nexport function assignExperimentVariant(\n experiment: Pick<\n HostExperiment,\n 'status' | 'variants' | 'winnerVariantId' | 'endAtMs'\n > & {\n $id?: string\n },\n experimentId: string,\n visitorId: string,\n nowMs: number = Date.now(),\n): ExperimentVariant | null {\n const variants = (experiment.variants ?? []).slice(\n 0,\n EXPERIMENT_MAX_VARIANTS,\n )\n if (!variants.length) return null\n if (experiment.winnerVariantId) {\n return (\n variants.find((variant) => variant.id === experiment.winnerVariantId) ??\n null\n )\n }\n if (experiment.status !== 'running') return null\n // Past the end date (AGL-273) visitors get the default until a winner\n // is declared — stats stop accruing rather than drifting post-test.\n if (experiment.endAtMs && nowMs > experiment.endAtMs) return null\n const totalWeight = variants.reduce(\n (sum, variant) => sum + Math.max(0, variant.weight ?? 1),\n 0,\n )\n if (totalWeight <= 0) return null\n const bucket = bucketHash(`${visitorId}:${experimentId}`) % totalWeight\n let cursor = 0\n for (const variant of variants) {\n cursor += Math.max(0, variant.weight ?? 1)\n if (bucket < cursor) return variant\n }\n return variants[variants.length - 1]\n}\n\n/** Validates an experiment doc shape; human-readable error or null. */\nexport function validateExperiment(\n experiment: HostExperiment,\n): string | null {\n if (!experiment.name?.trim()) return 'Name the experiment'\n if (!['screen', 'section', 'email'].includes(experiment.target)) {\n return 'Pick what the experiment tests'\n }\n const variants = experiment.variants ?? []\n if (variants.length < 2) return 'Add at least two variants'\n if (variants.length > EXPERIMENT_MAX_VARIANTS) {\n return `Experiments are capped at ${EXPERIMENT_MAX_VARIANTS} variants`\n }\n const ids = new Set(variants.map((variant) => variant.id))\n if (ids.size !== variants.length) return 'Variant ids must be unique'\n if (experiment.target === 'section' && !experiment.nodeId?.trim()) {\n return 'Section experiments need the canvas element id'\n }\n if (\n (experiment.target === 'screen' || experiment.target === 'section') &&\n !experiment.screenId?.trim()\n ) {\n return 'Pick the page under test'\n }\n if (experiment.autoWinner) {\n const { minExposures, confidence } = experiment.autoWinner\n if (!Number.isFinite(minExposures) || minExposures < 1) {\n return 'Auto-winner needs a minimum exposure count of at least 1'\n }\n if (!Number.isFinite(confidence) || confidence <= 0.5 || confidence >= 1) {\n return 'Auto-winner confidence must be between 0.5 and 1'\n }\n }\n return null\n}\n\n/** Naive conversion-rate summary the results table renders. */\nexport function summarizeVariantStats(stats: {\n exposures?: number\n conversions?: number\n}): { exposures: number; conversions: number; rate: number } {\n const exposures = Math.max(0, stats.exposures ?? 0)\n const conversions = Math.max(0, stats.conversions ?? 0)\n return {\n exposures,\n conversions,\n rate: exposures > 0 ? conversions / exposures : 0,\n }\n}\n\nexport interface VariantComparison {\n /** Relative conversion-rate lift vs the control; null when unmeasurable. */\n lift: number | null\n /**\n * One-sided confidence (0..1) that the challenger genuinely beats the\n * control — a two-proportion z-test through the normal CDF. Null until\n * both variants have exposures and at least one conversion exists.\n */\n confidence: number | null\n}\n\n/** Abramowitz–Stegun erf approximation (max error ~1.5e-7). */\nfunction erf(x: number): number {\n const sign = x < 0 ? -1 : 1\n const t = 1 / (1 + 0.3275911 * Math.abs(x))\n const y =\n 1 -\n (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t -\n 0.284496736) *\n t +\n 0.254829592) *\n t *\n Math.exp(-x * x)\n return sign * y\n}\n\n/** Standard normal CDF. */\nfunction normalCdf(z: number): number {\n return 0.5 * (1 + erf(z / Math.SQRT2))\n}\n\n/**\n * Compares a challenger variant against the control (AGL-265): lift =\n * relative rate change; confidence = P(challenger > control) from a\n * pooled two-proportion z-test. Small samples return null confidence\n * rather than a misleading number.\n */\nexport function compareVariants(\n control: { exposures?: number; conversions?: number },\n challenger: { exposures?: number; conversions?: number },\n): VariantComparison {\n const a = summarizeVariantStats(control)\n const b = summarizeVariantStats(challenger)\n const lift = a.rate > 0 ? (b.rate - a.rate) / a.rate : null\n if (\n a.exposures < 1 ||\n b.exposures < 1 ||\n a.conversions + b.conversions < 1\n ) {\n return { lift, confidence: null }\n }\n const pooled =\n (a.conversions + b.conversions) / (a.exposures + b.exposures)\n const standardError = Math.sqrt(\n pooled * (1 - pooled) * (1 / a.exposures + 1 / b.exposures),\n )\n if (!Number.isFinite(standardError) || standardError === 0) {\n return { lift, confidence: null }\n }\n const z = (b.rate - a.rate) / standardError\n return { lift, confidence: normalCdf(z) }\n}\n\n/** Per-variant counters, keyed by variant id, as `experiments/{id}/stats` holds them. */\nexport type ExperimentStatsByVariant = Record<\n string,\n { exposures?: number; conversions?: number } | undefined\n>\n\n/** One variant's line in the results table. */\nexport interface ExperimentResultRow {\n variant: ExperimentVariant\n summary: { exposures: number; conversions: number; rate: number }\n /**\n * Lift and confidence against the control — the FIRST variant, which is\n * compared against nothing and so carries `null` here.\n */\n comparison: VariantComparison | null\n /** It has exposures and no variant converts at a higher rate. Ties all lead. */\n leader: boolean\n /** The variant the experiment finished on. */\n winner: boolean\n}\n\n/**\n * The results table's rows, in variant order: each variant's figures, its\n * comparison with the control (AGL-265), and whether it leads or won.\n */\nexport function experimentResultRows(\n experiment: Pick<HostExperiment, 'variants' | 'winnerVariantId'>,\n stats: ExperimentStatsByVariant,\n): ExperimentResultRow[] {\n const variants = experiment.variants ?? []\n const summaries = variants.map((variant) =>\n summarizeVariantStats(stats[variant.id] ?? {}),\n )\n const control = variants[0]\n return variants.map((variant, index) => {\n const summary = summaries[index]\n return {\n variant,\n summary,\n comparison:\n index > 0 && control\n ? compareVariants(stats[control.id] ?? {}, stats[variant.id] ?? {})\n : null,\n leader:\n summary.exposures > 0 &&\n summaries.every((other) => summary.rate >= other.rate),\n winner: experiment.winnerVariantId === variant.id,\n }\n })\n}\n\n/**\n * A comparison as the results table prints it: `+12% · 97% conf.`, a dash\n * for a lift the control's zero rate cannot express, `needs data` until the\n * z-test has something to test, and `control` for the variant everything is\n * measured against.\n */\nexport function describeVariantComparison(\n comparison: VariantComparison | null,\n): string {\n if (!comparison) return 'control'\n const lift =\n comparison.lift != null\n ? `${comparison.lift >= 0 ? '+' : ''}${(comparison.lift * 100).toFixed(0)}%`\n : '—'\n const confidence =\n comparison.confidence != null\n ? ` · ${(comparison.confidence * 100).toFixed(0)}% conf.`\n : ' · needs data'\n return `${lift}${confidence}`\n}\n\n/**\n * Auto-winner evaluation (AGL-273): decides a running experiment from\n * per-variant stats once thresholds are met. The first variant is the\n * control. Undecidable states return null: any arm still under\n * `minExposures`, or no challenger confidently better while some remain\n * in the uncertain middle. Control wins only when every challenger is\n * confidently worse (its win probability is below `1 - confidence`).\n */\nexport function evaluateAutoWinner(\n experiment: Pick<HostExperiment, 'status' | 'variants' | 'autoWinner'>,\n statsByVariant: Record<\n string,\n { exposures?: number; conversions?: number } | undefined\n >,\n): { variantId: string; confidence: number } | null {\n const config = experiment.autoWinner\n if (!config || experiment.status !== 'running') return null\n const variants = (experiment.variants ?? []).slice(\n 0,\n EXPERIMENT_MAX_VARIANTS,\n )\n if (variants.length < 2) return null\n const controlStats = statsByVariant[variants[0].id] ?? {}\n if ((controlStats.exposures ?? 0) < config.minExposures) return null\n let best: { variantId: string; confidence: number } | null = null\n let everyLoses = true\n for (const challenger of variants.slice(1)) {\n const challengerStats = statsByVariant[challenger.id] ?? {}\n if ((challengerStats.exposures ?? 0) < config.minExposures) return null\n const { confidence } = compareVariants(controlStats, challengerStats)\n if (confidence === null) return null\n if (\n confidence >= config.confidence &&\n (!best || confidence > best.confidence)\n ) {\n best = { variantId: challenger.id, confidence }\n }\n if (confidence > 1 - config.confidence) everyLoses = false\n }\n if (best) return best\n if (everyLoses) {\n return { variantId: variants[0].id, confidence: config.confidence }\n }\n return null\n}\n"],"names":["EXPERIMENT_MAX_VARIANTS","bucketHash","value","hash","index","length","charCodeAt","Math","imul","assignExperimentVariant","experiment","experimentId","visitorId","nowMs","Date","now","variants","slice","winnerVariantId","find","variant","id","status","endAtMs","totalWeight","reduce","sum","max","weight","bucket","cursor","validateExperiment","name","trim","includes","target","ids","Set","map","size","nodeId","screenId","autoWinner","minExposures","confidence","Number","isFinite","summarizeVariantStats","stats","exposures","conversions","rate","erf","x","sign","t","abs","y","exp","normalCdf","z","SQRT2","compareVariants","control","challenger","a","b","lift","pooled","standardError","sqrt","experimentResultRows","summaries","summary","comparison","leader","every","other","winner","describeVariantComparison","toFixed","evaluateAutoWinner","statsByVariant","controlStats","config","best","everyLoses","challengerStats","variantId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GA+CD,OAAO,MAAMA,0BAA0B,EAAC;AAExC,sEAAsE,GACtE,SAASC,WAAWC,KAAa;IAC/B,IAAIC,OAAO;IACX,IAAK,IAAIC,QAAQ,GAAGA,QAAQF,MAAMG,MAAM,EAAED,SAAS,EAAG;QACpDD,QAAQD,MAAMI,UAAU,CAACF;QACzBD,OAAOI,KAAKC,IAAI,CAACL,MAAM;IACzB;IACA,OAAOA,SAAS;AAClB;AAEA;;;;CAIC,GACD,OAAO,SAASM,wBACdC,UAKC,EACDC,YAAoB,EACpBC,SAAiB,EACjBC,QAAgBC,KAAKC,GAAG,EAAE;QAERL;IAAlB,MAAMM,WAAW,EAACN,uBAAAA,WAAWM,QAAQ,YAAnBN,uBAAuB,EAAE,EAAEO,KAAK,CAChD,GACAjB;IAEF,IAAI,CAACgB,SAASX,MAAM,EAAE,OAAO;IAC7B,IAAIK,WAAWQ,eAAe,EAAE;YAE5BF;QADF,QACEA,iBAAAA,SAASG,IAAI,CAAC,CAACC,UAAYA,QAAQC,EAAE,KAAKX,WAAWQ,eAAe,aAApEF,iBACA;IAEJ;IACA,IAAIN,WAAWY,MAAM,KAAK,WAAW,OAAO;IAC5C,sEAAsE;IACtE,oEAAoE;IACpE,IAAIZ,WAAWa,OAAO,IAAIV,QAAQH,WAAWa,OAAO,EAAE,OAAO;IAC7D,MAAMC,cAAcR,SAASS,MAAM,CACjC,CAACC,KAAKN;YAA8BA;eAAlBM,MAAMnB,KAAKoB,GAAG,CAAC,IAAGP,kBAAAA,QAAQQ,MAAM,YAAdR,kBAAkB;OACtD;IAEF,IAAII,eAAe,GAAG,OAAO;IAC7B,MAAMK,SAAS5B,WAAW,GAAGW,UAAU,CAAC,EAAED,cAAc,IAAIa;IAC5D,IAAIM,SAAS;IACb,KAAK,MAAMV,WAAWJ,SAAU;YACRI;QAAtBU,UAAUvB,KAAKoB,GAAG,CAAC,IAAGP,kBAAAA,QAAQQ,MAAM,YAAdR,kBAAkB;QACxC,IAAIS,SAASC,QAAQ,OAAOV;IAC9B;IACA,OAAOJ,QAAQ,CAACA,SAASX,MAAM,GAAG,EAAE;AACtC;AAEA,qEAAqE,GACrE,OAAO,SAAS0B,mBACdrB,UAA0B;QAMTA;QAJZA,kBAWmCA,oBAKrCA;IAhBH,IAAI,GAACA,mBAAAA,WAAWsB,IAAI,qBAAftB,iBAAiBuB,IAAI,KAAI,OAAO;IACrC,IAAI,CAAC;QAAC;QAAU;QAAW;KAAQ,CAACC,QAAQ,CAACxB,WAAWyB,MAAM,GAAG;QAC/D,OAAO;IACT;IACA,MAAMnB,YAAWN,uBAAAA,WAAWM,QAAQ,YAAnBN,uBAAuB,EAAE;IAC1C,IAAIM,SAASX,MAAM,GAAG,GAAG,OAAO;IAChC,IAAIW,SAASX,MAAM,GAAGL,yBAAyB;QAC7C,OAAO,CAAC,0BAA0B,EAAEA,wBAAwB,SAAS,CAAC;IACxE;IACA,MAAMoC,MAAM,IAAIC,IAAIrB,SAASsB,GAAG,CAAC,CAAClB,UAAYA,QAAQC,EAAE;IACxD,IAAIe,IAAIG,IAAI,KAAKvB,SAASX,MAAM,EAAE,OAAO;IACzC,IAAIK,WAAWyB,MAAM,KAAK,aAAa,GAACzB,qBAAAA,WAAW8B,MAAM,qBAAjB9B,mBAAmBuB,IAAI,KAAI;QACjE,OAAO;IACT;IACA,IACE,AAACvB,CAAAA,WAAWyB,MAAM,KAAK,YAAYzB,WAAWyB,MAAM,KAAK,SAAQ,KACjE,GAACzB,uBAAAA,WAAW+B,QAAQ,qBAAnB/B,qBAAqBuB,IAAI,KAC1B;QACA,OAAO;IACT;IACA,IAAIvB,WAAWgC,UAAU,EAAE;QACzB,MAAM,EAAEC,YAAY,EAAEC,UAAU,EAAE,GAAGlC,WAAWgC,UAAU;QAC1D,IAAI,CAACG,OAAOC,QAAQ,CAACH,iBAAiBA,eAAe,GAAG;YACtD,OAAO;QACT;QACA,IAAI,CAACE,OAAOC,QAAQ,CAACF,eAAeA,cAAc,OAAOA,cAAc,GAAG;YACxE,OAAO;QACT;IACF;IACA,OAAO;AACT;AAEA,6DAA6D,GAC7D,OAAO,SAASG,sBAAsBC,KAGrC;QAC+BA,kBACEA;IADhC,MAAMC,YAAY1C,KAAKoB,GAAG,CAAC,IAAGqB,mBAAAA,MAAMC,SAAS,YAAfD,mBAAmB;IACjD,MAAME,cAAc3C,KAAKoB,GAAG,CAAC,IAAGqB,qBAAAA,MAAME,WAAW,YAAjBF,qBAAqB;IACrD,OAAO;QACLC;QACAC;QACAC,MAAMF,YAAY,IAAIC,cAAcD,YAAY;IAClD;AACF;AAaA,6DAA6D,GAC7D,SAASG,IAAIC,CAAS;IACpB,MAAMC,OAAOD,IAAI,IAAI,CAAC,IAAI;IAC1B,MAAME,IAAI,IAAK,CAAA,IAAI,YAAYhD,KAAKiD,GAAG,CAACH,EAAC;IACzC,MAAMI,IACJ,IACA,AAAC,CAAA,AAAC,CAAA,AAAC,CAAA,AAAE,CAAA,cAAcF,IAAI,WAAU,IAAKA,IAAK,WAAU,IAAKA,IACxD,WAAU,IACVA,IACA,WAAU,IACVA,IACAhD,KAAKmD,GAAG,CAAC,CAACL,IAAIA;IAClB,OAAOC,OAAOG;AAChB;AAEA,yBAAyB,GACzB,SAASE,UAAUC,CAAS;IAC1B,OAAO,MAAO,CAAA,IAAIR,IAAIQ,IAAIrD,KAAKsD,KAAK,CAAA;AACtC;AAEA;;;;;CAKC,GACD,OAAO,SAASC,gBACdC,OAAqD,EACrDC,UAAwD;IAExD,MAAMC,IAAIlB,sBAAsBgB;IAChC,MAAMG,IAAInB,sBAAsBiB;IAChC,MAAMG,OAAOF,EAAEd,IAAI,GAAG,IAAI,AAACe,CAAAA,EAAEf,IAAI,GAAGc,EAAEd,IAAI,AAAD,IAAKc,EAAEd,IAAI,GAAG;IACvD,IACEc,EAAEhB,SAAS,GAAG,KACdiB,EAAEjB,SAAS,GAAG,KACdgB,EAAEf,WAAW,GAAGgB,EAAEhB,WAAW,GAAG,GAChC;QACA,OAAO;YAAEiB;YAAMvB,YAAY;QAAK;IAClC;IACA,MAAMwB,SACJ,AAACH,CAAAA,EAAEf,WAAW,GAAGgB,EAAEhB,WAAW,AAAD,IAAMe,CAAAA,EAAEhB,SAAS,GAAGiB,EAAEjB,SAAS,AAAD;IAC7D,MAAMoB,gBAAgB9D,KAAK+D,IAAI,CAC7BF,SAAU,CAAA,IAAIA,MAAK,IAAM,CAAA,IAAIH,EAAEhB,SAAS,GAAG,IAAIiB,EAAEjB,SAAS,AAAD;IAE3D,IAAI,CAACJ,OAAOC,QAAQ,CAACuB,kBAAkBA,kBAAkB,GAAG;QAC1D,OAAO;YAAEF;YAAMvB,YAAY;QAAK;IAClC;IACA,MAAMgB,IAAI,AAACM,CAAAA,EAAEf,IAAI,GAAGc,EAAEd,IAAI,AAAD,IAAKkB;IAC9B,OAAO;QAAEF;QAAMvB,YAAYe,UAAUC;IAAG;AAC1C;AAuBA;;;CAGC,GACD,OAAO,SAASW,qBACd7D,UAAgE,EAChEsC,KAA+B;QAEdtC;IAAjB,MAAMM,YAAWN,uBAAAA,WAAWM,QAAQ,YAAnBN,uBAAuB,EAAE;IAC1C,MAAM8D,YAAYxD,SAASsB,GAAG,CAAC,CAAClB;YACR4B;eAAtBD,uBAAsBC,oBAAAA,KAAK,CAAC5B,QAAQC,EAAE,CAAC,YAAjB2B,oBAAqB,CAAC;;IAE9C,MAAMe,UAAU/C,QAAQ,CAAC,EAAE;IAC3B,OAAOA,SAASsB,GAAG,CAAC,CAAClB,SAAShB;YAOJ4C,mBAAyBA;QANjD,MAAMyB,UAAUD,SAAS,CAACpE,MAAM;QAChC,OAAO;YACLgB;YACAqD;YACAC,YACEtE,QAAQ,KAAK2D,UACTD,iBAAgBd,oBAAAA,KAAK,CAACe,QAAQ1C,EAAE,CAAC,YAAjB2B,oBAAqB,CAAC,IAAGA,oBAAAA,KAAK,CAAC5B,QAAQC,EAAE,CAAC,YAAjB2B,oBAAqB,CAAC,KAC/D;YACN2B,QACEF,QAAQxB,SAAS,GAAG,KACpBuB,UAAUI,KAAK,CAAC,CAACC,QAAUJ,QAAQtB,IAAI,IAAI0B,MAAM1B,IAAI;YACvD2B,QAAQpE,WAAWQ,eAAe,KAAKE,QAAQC,EAAE;QACnD;IACF;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAAS0D,0BACdL,UAAoC;IAEpC,IAAI,CAACA,YAAY,OAAO;IACxB,MAAMP,OACJO,WAAWP,IAAI,IAAI,OACf,GAAGO,WAAWP,IAAI,IAAI,IAAI,MAAM,KAAK,AAACO,CAAAA,WAAWP,IAAI,GAAG,GAAE,EAAGa,OAAO,CAAC,GAAG,CAAC,CAAC,GAC1E;IACN,MAAMpC,aACJ8B,WAAW9B,UAAU,IAAI,OACrB,CAAC,GAAG,EAAE,AAAC8B,CAAAA,WAAW9B,UAAU,GAAG,GAAE,EAAGoC,OAAO,CAAC,GAAG,OAAO,CAAC,GACvD;IACN,OAAO,GAAGb,OAAOvB,YAAY;AAC/B;AAEA;;;;;;;CAOC,GACD,OAAO,SAASqC,mBACdvE,UAAsE,EACtEwE,cAGC;QAIiBxE,sBAKGwE,8BAChBC;IARL,MAAMC,SAAS1E,WAAWgC,UAAU;IACpC,IAAI,CAAC0C,UAAU1E,WAAWY,MAAM,KAAK,WAAW,OAAO;IACvD,MAAMN,WAAW,EAACN,uBAAAA,WAAWM,QAAQ,YAAnBN,uBAAuB,EAAE,EAAEO,KAAK,CAChD,GACAjB;IAEF,IAAIgB,SAASX,MAAM,GAAG,GAAG,OAAO;IAChC,MAAM8E,gBAAeD,+BAAAA,cAAc,CAAClE,QAAQ,CAAC,EAAE,CAACK,EAAE,CAAC,YAA9B6D,+BAAkC,CAAC;IACxD,IAAI,EAACC,0BAAAA,aAAalC,SAAS,YAAtBkC,0BAA0B,KAAKC,OAAOzC,YAAY,EAAE,OAAO;IAChE,IAAI0C,OAAyD;IAC7D,IAAIC,aAAa;IACjB,KAAK,MAAMtB,cAAchD,SAASC,KAAK,CAAC,GAAI;YAClBiE,+BACnBK;QADL,MAAMA,mBAAkBL,gCAAAA,cAAc,CAAClB,WAAW3C,EAAE,CAAC,YAA7B6D,gCAAiC,CAAC;QAC1D,IAAI,EAACK,6BAAAA,gBAAgBtC,SAAS,YAAzBsC,6BAA6B,KAAKH,OAAOzC,YAAY,EAAE,OAAO;QACnE,MAAM,EAAEC,UAAU,EAAE,GAAGkB,gBAAgBqB,cAAcI;QACrD,IAAI3C,eAAe,MAAM,OAAO;QAChC,IACEA,cAAcwC,OAAOxC,UAAU,IAC9B,CAAA,CAACyC,QAAQzC,aAAayC,KAAKzC,UAAU,AAAD,GACrC;YACAyC,OAAO;gBAAEG,WAAWxB,WAAW3C,EAAE;gBAAEuB;YAAW;QAChD;QACA,IAAIA,aAAa,IAAIwC,OAAOxC,UAAU,EAAE0C,aAAa;IACvD;IACA,IAAID,MAAM,OAAOA;IACjB,IAAIC,YAAY;QACd,OAAO;YAAEE,WAAWxE,QAAQ,CAAC,EAAE,CAACK,EAAE;YAAEuB,YAAYwC,OAAOxC,UAAU;QAAC;IACpE;IACA,OAAO;AACT"}
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
/** Why a partly-delivered email will not run another batch. */
|
|
18
|
+
export type CampaignBatchStop =
|
|
19
|
+
/** Nothing is left to address. */
|
|
20
|
+
'complete'
|
|
21
|
+
/** {@link EMAIL_MAX_SEND_BATCHES} reached. */
|
|
22
|
+
| 'batch-limit'
|
|
23
|
+
/** The batch settled nobody, so another one would settle nobody either. */
|
|
24
|
+
| 'no-progress';
|
|
25
|
+
/** What happens to an email after one batch of it has been delivered. */
|
|
26
|
+
export interface CampaignBatchPlan {
|
|
27
|
+
/** People this email has resolved and not yet addressed. */
|
|
28
|
+
remaining: number;
|
|
29
|
+
/** Batches this email has now run, including the one just finished. */
|
|
30
|
+
batch: number;
|
|
31
|
+
/** True when another batch will run. */
|
|
32
|
+
resuming: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Why no further batch will run. Null while {@link resuming} is true —
|
|
35
|
+
* a reason for a state that has not happened is a reason a surface would
|
|
36
|
+
* show.
|
|
37
|
+
*/
|
|
38
|
+
stop: CampaignBatchStop | null;
|
|
39
|
+
}
|
|
40
|
+
export interface CampaignBatchPlanInput {
|
|
41
|
+
/** People this batch could have addressed, before the per-send cap. */
|
|
42
|
+
mailable: number;
|
|
43
|
+
/** People it did address — `min(mailable, perSend)`. */
|
|
44
|
+
addressed: number;
|
|
45
|
+
/**
|
|
46
|
+
* Of those it addressed, how many are left for a later batch to try again:
|
|
47
|
+
* the tail an hourly deferral cut off, and nothing else. A recipient the
|
|
48
|
+
* send SETTLED — delivered, suppressed or off-topic — is not retryable and
|
|
49
|
+
* is not counted here.
|
|
50
|
+
*/
|
|
51
|
+
retryable: number;
|
|
52
|
+
/**
|
|
53
|
+
* People this batch settled: delivered plus permanently excluded. Zero is
|
|
54
|
+
* what {@link CampaignBatchStop} `no-progress` is, and it is the reason
|
|
55
|
+
* this is an input rather than a derivation — a batch that addressed 500
|
|
56
|
+
* people and settled none of them will address the same 500 next time.
|
|
57
|
+
*/
|
|
58
|
+
settled: number;
|
|
59
|
+
/** Batches run before this one. */
|
|
60
|
+
batchesSoFar: number;
|
|
61
|
+
/** Injectable for tests; defaults to the shipped guard. */
|
|
62
|
+
maxBatches?: number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether a partly-delivered email runs again, and how much is left.
|
|
66
|
+
*
|
|
67
|
+
* Pure, so the termination rule can be read and tested without a send. The
|
|
68
|
+
* one property it exists to hold is that a campaign always terminates:
|
|
69
|
+
* `resuming` is false whenever there is nothing left, whenever the batch
|
|
70
|
+
* guard is reached, and whenever a batch settled nobody — the last of which
|
|
71
|
+
* is the only way a self-rescheduling job can loop.
|
|
72
|
+
*/
|
|
73
|
+
export declare function campaignBatchPlan(input: CampaignBatchPlanInput): CampaignBatchPlan;
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
*/ import { EMAIL_MAX_SEND_BATCHES } from "@aglyn/shared-util-email";
|
|
17
|
+
/**
|
|
18
|
+
* Whether a campaign that has delivered one batch runs another.
|
|
19
|
+
*
|
|
20
|
+
* A campaign larger than one send's recipient cap goes out in batches: the
|
|
21
|
+
* sender addresses what it may, and reschedules itself for the rest. This is
|
|
22
|
+
* the rule that decides whether it does, pure so it can be read and tested
|
|
23
|
+
* without a send. The one property it exists to hold is that a campaign always
|
|
24
|
+
* terminates. The ceiling on how many batches any bulk send may take,
|
|
25
|
+
* {@link EMAIL_MAX_SEND_BATCHES}, is the mail rail's; the rule for this sender
|
|
26
|
+
* is the campaign's own.
|
|
27
|
+
*/ /** Clamps a raw count to a positive integer, or returns `fallback`. */ function positiveInt(raw, fallback) {
|
|
28
|
+
const value = Number(raw);
|
|
29
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a partly-delivered email runs again, and how much is left.
|
|
33
|
+
*
|
|
34
|
+
* Pure, so the termination rule can be read and tested without a send. The
|
|
35
|
+
* one property it exists to hold is that a campaign always terminates:
|
|
36
|
+
* `resuming` is false whenever there is nothing left, whenever the batch
|
|
37
|
+
* guard is reached, and whenever a batch settled nobody — the last of which
|
|
38
|
+
* is the only way a self-rescheduling job can loop.
|
|
39
|
+
*/ export function campaignBatchPlan(input) {
|
|
40
|
+
const mailable = positiveInt(input.mailable, 0);
|
|
41
|
+
const addressed = Math.min(mailable, positiveInt(input.addressed, 0));
|
|
42
|
+
const retryable = Math.min(addressed, positiveInt(input.retryable, 0));
|
|
43
|
+
const settled = positiveInt(input.settled, 0);
|
|
44
|
+
const batch = positiveInt(input.batchesSoFar, 0) + 1;
|
|
45
|
+
const maxBatches = positiveInt(input.maxBatches, EMAIL_MAX_SEND_BATCHES);
|
|
46
|
+
const remaining = Math.max(0, mailable - addressed) + retryable;
|
|
47
|
+
if (remaining <= 0) {
|
|
48
|
+
return {
|
|
49
|
+
remaining: 0,
|
|
50
|
+
batch,
|
|
51
|
+
resuming: false,
|
|
52
|
+
stop: 'complete'
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (batch >= maxBatches) {
|
|
56
|
+
return {
|
|
57
|
+
remaining,
|
|
58
|
+
batch,
|
|
59
|
+
resuming: false,
|
|
60
|
+
stop: 'batch-limit'
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (settled <= 0) {
|
|
64
|
+
return {
|
|
65
|
+
remaining,
|
|
66
|
+
batch,
|
|
67
|
+
resuming: false,
|
|
68
|
+
stop: 'no-progress'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
remaining,
|
|
73
|
+
batch,
|
|
74
|
+
resuming: true,
|
|
75
|
+
stop: null
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=campaign-batch-plan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-batch-plan.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\nimport { EMAIL_MAX_SEND_BATCHES } from '@aglyn/shared-util-email'\n\n/**\n * Whether a campaign that has delivered one batch runs another.\n *\n * A campaign larger than one send's recipient cap goes out in batches: the\n * sender addresses what it may, and reschedules itself for the rest. This is\n * the rule that decides whether it does, pure so it can be read and tested\n * without a send. The one property it exists to hold is that a campaign always\n * terminates. The ceiling on how many batches any bulk send may take,\n * {@link EMAIL_MAX_SEND_BATCHES}, is the mail rail's; the rule for this sender\n * is the campaign's own.\n */\n\n/** Clamps a raw count to a positive integer, or returns `fallback`. */\nfunction positiveInt(raw: unknown, fallback: number): number {\n const value = Number(raw)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback\n}\n\n/** Why a partly-delivered email will not run another batch. */\nexport type CampaignBatchStop =\n /** Nothing is left to address. */\n | 'complete'\n /** {@link EMAIL_MAX_SEND_BATCHES} reached. */\n | 'batch-limit'\n /** The batch settled nobody, so another one would settle nobody either. */\n | 'no-progress'\n\n/** What happens to an email after one batch of it has been delivered. */\nexport interface CampaignBatchPlan {\n /** People this email has resolved and not yet addressed. */\n remaining: number\n /** Batches this email has now run, including the one just finished. */\n batch: number\n /** True when another batch will run. */\n resuming: boolean\n /**\n * Why no further batch will run. Null while {@link resuming} is true —\n * a reason for a state that has not happened is a reason a surface would\n * show.\n */\n stop: CampaignBatchStop | null\n}\n\nexport interface CampaignBatchPlanInput {\n /** People this batch could have addressed, before the per-send cap. */\n mailable: number\n /** People it did address — `min(mailable, perSend)`. */\n addressed: number\n /**\n * Of those it addressed, how many are left for a later batch to try again:\n * the tail an hourly deferral cut off, and nothing else. A recipient the\n * send SETTLED — delivered, suppressed or off-topic — is not retryable and\n * is not counted here.\n */\n retryable: number\n /**\n * People this batch settled: delivered plus permanently excluded. Zero is\n * what {@link CampaignBatchStop} `no-progress` is, and it is the reason\n * this is an input rather than a derivation — a batch that addressed 500\n * people and settled none of them will address the same 500 next time.\n */\n settled: number\n /** Batches run before this one. */\n batchesSoFar: number\n /** Injectable for tests; defaults to the shipped guard. */\n maxBatches?: number\n}\n\n/**\n * Whether a partly-delivered email runs again, and how much is left.\n *\n * Pure, so the termination rule can be read and tested without a send. The\n * one property it exists to hold is that a campaign always terminates:\n * `resuming` is false whenever there is nothing left, whenever the batch\n * guard is reached, and whenever a batch settled nobody — the last of which\n * is the only way a self-rescheduling job can loop.\n */\nexport function campaignBatchPlan(\n input: CampaignBatchPlanInput,\n): CampaignBatchPlan {\n const mailable = positiveInt(input.mailable, 0)\n const addressed = Math.min(mailable, positiveInt(input.addressed, 0))\n const retryable = Math.min(addressed, positiveInt(input.retryable, 0))\n const settled = positiveInt(input.settled, 0)\n const batch = positiveInt(input.batchesSoFar, 0) + 1\n const maxBatches = positiveInt(input.maxBatches, EMAIL_MAX_SEND_BATCHES)\n const remaining = Math.max(0, mailable - addressed) + retryable\n\n if (remaining <= 0) {\n return { remaining: 0, batch, resuming: false, stop: 'complete' }\n }\n if (batch >= maxBatches) {\n return { remaining, batch, resuming: false, stop: 'batch-limit' }\n }\n if (settled <= 0) {\n return { remaining, batch, resuming: false, stop: 'no-progress' }\n }\n return { remaining, batch, resuming: true, stop: null }\n}\n"],"names":["EMAIL_MAX_SEND_BATCHES","positiveInt","raw","fallback","value","Number","isFinite","Math","floor","campaignBatchPlan","input","mailable","addressed","min","retryable","settled","batch","batchesSoFar","maxBatches","remaining","max","resuming","stop"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,2BAA0B;AAEjE;;;;;;;;;;CAUC,GAED,qEAAqE,GACrE,SAASC,YAAYC,GAAY,EAAEC,QAAgB;IACjD,MAAMC,QAAQC,OAAOH;IACrB,OAAOG,OAAOC,QAAQ,CAACF,UAAUA,QAAQ,IAAIG,KAAKC,KAAK,CAACJ,SAASD;AACnE;AAoDA;;;;;;;;CAQC,GACD,OAAO,SAASM,kBACdC,KAA6B;IAE7B,MAAMC,WAAWV,YAAYS,MAAMC,QAAQ,EAAE;IAC7C,MAAMC,YAAYL,KAAKM,GAAG,CAACF,UAAUV,YAAYS,MAAME,SAAS,EAAE;IAClE,MAAME,YAAYP,KAAKM,GAAG,CAACD,WAAWX,YAAYS,MAAMI,SAAS,EAAE;IACnE,MAAMC,UAAUd,YAAYS,MAAMK,OAAO,EAAE;IAC3C,MAAMC,QAAQf,YAAYS,MAAMO,YAAY,EAAE,KAAK;IACnD,MAAMC,aAAajB,YAAYS,MAAMQ,UAAU,EAAElB;IACjD,MAAMmB,YAAYZ,KAAKa,GAAG,CAAC,GAAGT,WAAWC,aAAaE;IAEtD,IAAIK,aAAa,GAAG;QAClB,OAAO;YAAEA,WAAW;YAAGH;YAAOK,UAAU;YAAOC,MAAM;QAAW;IAClE;IACA,IAAIN,SAASE,YAAY;QACvB,OAAO;YAAEC;YAAWH;YAAOK,UAAU;YAAOC,MAAM;QAAc;IAClE;IACA,IAAIP,WAAW,GAAG;QAChB,OAAO;YAAEI;YAAWH;YAAOK,UAAU;YAAOC,MAAM;QAAc;IAClE;IACA,OAAO;QAAEH;QAAWH;QAAOK,UAAU;QAAMC,MAAM;IAAK;AACxD"}
|