@aglyn/plugins-marketing 1.0.0-beta.178 → 1.0.0-beta.180

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 (71) hide show
  1. package/README.md +3 -3
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-conversions-card.js +22 -4
  4. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  5. package/src/lib/components/campaign-detail-card.js +3 -4
  6. package/src/lib/components/campaign-detail-card.js.map +1 -1
  7. package/src/lib/components/campaign-report-card.js +3 -3
  8. package/src/lib/components/campaign-report-card.js.map +1 -1
  9. package/src/lib/components/email-compose-card.d.ts +5 -2
  10. package/src/lib/components/email-compose-card.js +2 -3
  11. package/src/lib/components/email-compose-card.js.map +1 -1
  12. package/src/lib/components/email-detail.d.ts +5 -2
  13. package/src/lib/components/email-detail.js +4 -4
  14. package/src/lib/components/email-detail.js.map +1 -1
  15. package/src/lib/components/email-messages-widget.d.ts +17 -2
  16. package/src/lib/components/email-messages-widget.js +33 -2
  17. package/src/lib/components/email-messages-widget.js.map +1 -1
  18. package/src/lib/components/emails-list-card.d.ts +9 -6
  19. package/src/lib/components/emails-list-card.js +9 -9
  20. package/src/lib/components/emails-list-card.js.map +1 -1
  21. package/src/lib/components/host-experiments-card.component.d.ts +2 -0
  22. package/src/lib/components/host-experiments-card.component.js +20 -25
  23. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  24. package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
  25. package/src/lib/components/host-marketing-summary-card.component.js +59 -68
  26. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -1
  27. package/src/lib/components/host-overlays-card.component.js +15 -37
  28. package/src/lib/components/host-overlays-card.component.js.map +1 -1
  29. package/src/lib/components/inbox-campaigns-widget.d.ts +23 -0
  30. package/src/lib/components/inbox-campaigns-widget.js +63 -0
  31. package/src/lib/components/inbox-campaigns-widget.js.map +1 -0
  32. package/src/lib/components/marketing-aggregates.d.ts +95 -0
  33. package/src/lib/components/marketing-aggregates.js +114 -0
  34. package/src/lib/components/marketing-aggregates.js.map +1 -0
  35. package/src/lib/components/marketing-console-page.js +32 -27
  36. package/src/lib/components/marketing-console-page.js.map +1 -1
  37. package/src/lib/components/marketing-console-sections.d.ts +24 -13
  38. package/src/lib/components/marketing-console-sections.js +37 -12
  39. package/src/lib/components/marketing-console-sections.js.map +1 -1
  40. package/src/lib/components/org-conversions-section.d.ts +32 -0
  41. package/src/lib/components/org-conversions-section.js +130 -0
  42. package/src/lib/components/org-conversions-section.js.map +1 -0
  43. package/src/lib/components/org-experiments-card.d.ts +32 -0
  44. package/src/lib/components/org-experiments-card.js +407 -0
  45. package/src/lib/components/org-experiments-card.js.map +1 -0
  46. package/src/lib/components/org-marketing-overview-card.d.ts +34 -0
  47. package/src/lib/components/org-marketing-overview-card.js +342 -0
  48. package/src/lib/components/org-marketing-overview-card.js.map +1 -0
  49. package/src/lib/components/org-overlays-card.d.ts +35 -0
  50. package/src/lib/components/org-overlays-card.js +345 -0
  51. package/src/lib/components/org-overlays-card.js.map +1 -0
  52. package/src/lib/components/org-site-picker-button.d.ts +26 -0
  53. package/src/lib/components/org-site-picker-button.js +104 -0
  54. package/src/lib/components/org-site-picker-button.js.map +1 -0
  55. package/src/lib/components/overlay-labels.d.ts +38 -0
  56. package/src/lib/components/overlay-labels.js +55 -0
  57. package/src/lib/components/overlay-labels.js.map +1 -0
  58. package/src/lib/components/use-emails-hub-path.d.ts +17 -8
  59. package/src/lib/components/use-emails-hub-path.js +29 -11
  60. package/src/lib/components/use-emails-hub-path.js.map +1 -1
  61. package/src/lib/components/use-one-shot-reads.d.ts +55 -0
  62. package/src/lib/components/use-one-shot-reads.js +168 -0
  63. package/src/lib/components/use-one-shot-reads.js.map +1 -0
  64. package/src/lib/model/experiments.d.ts +35 -0
  65. package/src/lib/model/experiments.js +34 -0
  66. package/src/lib/model/experiments.js.map +1 -1
  67. package/src/lib/model/overlays.d.ts +13 -2
  68. package/src/lib/model/overlays.js +18 -6
  69. package/src/lib/model/overlays.js.map +1 -1
  70. package/src/lib/plugin.js +13 -8
  71. package/src/lib/plugin.js.map +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/overlay-labels.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 type { HostOverlay } from '../model'\n\n/**\n * How an overlay row reads, in the site's own list and in the\n * organization's list of every site's overlays — one spelling for both, so\n * the same overlay does not read two ways one click apart.\n */\n\n/**\n * The overlay's name, or the opening of its copy when nobody named it — the\n * editor stores an empty name as `null` — or its id when it has neither.\n */\nexport function overlayDisplayName(\n overlay: HostOverlay & { $id?: string },\n): string {\n if (overlay.name) return overlay.name\n const copy =\n overlay.kind === 'bar' ? overlay.bar?.text : overlay.popup?.headline\n return (copy ?? '').slice(0, 32) || overlay.$id || ''\n}\n\n/** The showing window, open ends as `…`, or `Always` with neither end set. */\nexport function overlayWindowLabel(\n overlay: Pick<HostOverlay, 'startAtMs' | 'endAtMs'>,\n): string {\n if (!overlay.startAtMs && !overlay.endAtMs) return 'Always'\n const day = (ms?: number) => (ms ? new Date(ms).toLocaleDateString() : '…')\n return `${day(overlay.startAtMs)} → ${day(overlay.endAtMs)}`\n}\n\n/** The page patterns it shows on, or `All pages` with none. */\nexport function overlayPagesLabel(\n overlay: Pick<HostOverlay, 'pathPatterns'>,\n): string {\n return overlay.pathPatterns?.length\n ? overlay.pathPatterns.join(', ')\n : 'All pages'\n}\n\n/**\n * Lifetime engagement (AGL-271) — the counters the beacon increments — or a\n * dash for an overlay nobody has seen yet.\n */\nexport function overlayEngagementLabel(\n stats: HostOverlay['stats'] | undefined,\n): string {\n const impressions = stats?.impressions ?? 0\n const clicks = stats?.clicks ?? 0\n const dismissals = stats?.dismissals ?? 0\n if (!impressions && !clicks && !dismissals) return '—'\n const parts = [`${impressions.toLocaleString()} views`]\n if (clicks) parts.push(`${clicks.toLocaleString()} clicks`)\n if (dismissals) parts.push(`${dismissals.toLocaleString()} dismissed`)\n return parts.join(' · ')\n}\n"],"names":["overlayDisplayName","overlay","name","copy","kind","bar","text","popup","headline","slice","$id","overlayWindowLabel","startAtMs","endAtMs","day","ms","Date","toLocaleDateString","overlayPagesLabel","pathPatterns","length","join","overlayEngagementLabel","stats","impressions","clicks","dismissals","parts","toLocaleString","push"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;CAIC,GAED;;;CAGC,GACD,OAAO,SAASA,mBACdC,OAAuC;QAIZA,cAAoBA;IAF/C,IAAIA,QAAQC,IAAI,EAAE,OAAOD,QAAQC,IAAI;IACrC,MAAMC,OACJF,QAAQG,IAAI,KAAK,SAAQH,eAAAA,QAAQI,GAAG,qBAAXJ,aAAaK,IAAI,IAAGL,iBAAAA,QAAQM,KAAK,qBAAbN,eAAeO,QAAQ;IACtE,OAAO,CAACL,eAAAA,OAAQ,IAAIM,KAAK,CAAC,GAAG,OAAOR,QAAQS,GAAG,IAAI;AACrD;AAEA,4EAA4E,GAC5E,OAAO,SAASC,mBACdV,OAAmD;IAEnD,IAAI,CAACA,QAAQW,SAAS,IAAI,CAACX,QAAQY,OAAO,EAAE,OAAO;IACnD,MAAMC,MAAM,CAACC,KAAiBA,KAAK,IAAIC,KAAKD,IAAIE,kBAAkB,KAAK;IACvE,OAAO,GAAGH,IAAIb,QAAQW,SAAS,EAAE,GAAG,EAAEE,IAAIb,QAAQY,OAAO,GAAG;AAC9D;AAEA,6DAA6D,GAC7D,OAAO,SAASK,kBACdjB,OAA0C;QAEnCA;IAAP,OAAOA,EAAAA,wBAAAA,QAAQkB,YAAY,qBAApBlB,sBAAsBmB,MAAM,IAC/BnB,QAAQkB,YAAY,CAACE,IAAI,CAAC,QAC1B;AACN;AAEA;;;CAGC,GACD,OAAO,SAASC,uBACdC,KAAuC;;IAEvC,MAAMC,sBAAcD,yBAAAA,MAAOC,WAAW,mBAAI;IAC1C,MAAMC,kBAASF,yBAAAA,MAAOE,MAAM,oBAAI;IAChC,MAAMC,sBAAaH,yBAAAA,MAAOG,UAAU,oBAAI;IACxC,IAAI,CAACF,eAAe,CAACC,UAAU,CAACC,YAAY,OAAO;IACnD,MAAMC,QAAQ;QAAC,GAAGH,YAAYI,cAAc,GAAG,MAAM,CAAC;KAAC;IACvD,IAAIH,QAAQE,MAAME,IAAI,CAAC,GAAGJ,OAAOG,cAAc,GAAG,OAAO,CAAC;IAC1D,IAAIF,YAAYC,MAAME,IAAI,CAAC,GAAGH,WAAWE,cAAc,GAAG,UAAU,CAAC;IACrE,OAAOD,MAAMN,IAAI,CAAC;AACpB"}
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The Emails console's URL under the site being read.
2
+ * The Emails console's URL: under the site being read, or the organization's
3
+ * own Emails page on a surface mounted over the org.
3
4
  *
4
5
  * A campaign's page links to two records it does not own — the message that
5
6
  * was sent inside it, and the template that message was built from — and both
@@ -12,12 +13,17 @@
12
13
  * Free, deliberately. The org slug and the subdomain are already in the URL
13
14
  * the console is on, so this reads the route rather than resolving the host
14
15
  * document — the two `getDoc`s that resolution costs would be paid on every
15
- * open of a campaign, to render a link.
16
+ * open of a campaign, to render a link. At the organization level the org
17
+ * mount already carries the slug.
18
+ *
19
+ * The org answer comes from the MOUNT rather than from the route having no
20
+ * site in it: a card is on the organization's page because it was handed the
21
+ * org mount, and that is the fact a link to the org's messages has to follow.
22
+ * A message is the org's document, so its page there is
23
+ * `/[orgSlug]/emails/messages/{id}` — the Emails page over every site.
16
24
  *
17
25
  * `null` before the params resolve, so callers render plain text rather than
18
- * a link to nowhere — and on the org Marketing hub, where there is no site
19
- * and so no Emails console; a card there builds its message links from the
20
- * org mount instead.
26
+ * a link to nowhere.
21
27
  */
22
28
  export declare function useEmailsHubPath(): string | null;
23
29
  /**
@@ -29,11 +35,14 @@ export declare function useEmailsHubPath(): string | null;
29
35
  * links to the campaign it came from, and those surfaces are handed their own
30
36
  * hub's `basePath`, not this one's.
31
37
  *
32
- * Unlike the Emails hub it has an answer with no site in the URL. Campaigns
33
- * belong to the organization, so the org-level CRM and the org Marketing hub
34
- * itself link a campaign to `/[orgSlug]/marketing/campaigns/{id}`.
38
+ * Campaigns belong to the organization, so the org-level CRM and the org
39
+ * Marketing hub itself link a campaign to `/[orgSlug]/marketing/campaigns/{id}`.
35
40
  */
36
41
  export declare function useMarketingHubPath(): string | null;
42
+ /** The organization's own Marketing hub, `/[orgSlug]/marketing`. */
43
+ export declare function orgMarketingHubPath(orgSlug: string): string;
44
+ /** The organization's own Emails page, `/[orgSlug]/emails`. */
45
+ export declare function orgEmailsHubPath(orgSlug: string): string;
37
46
  /**
38
47
  * Any sibling hub's URL under the site being read, by plugin slug.
39
48
  *
@@ -16,8 +16,10 @@
16
16
  */ 'use client';
17
17
  import { buildRoute, Route } from "@aglyn/aglyn";
18
18
  import { useParams } from "next/navigation";
19
+ import { useMarketingOrgMount } from "./marketing-org-mount.js";
19
20
  /**
20
- * The Emails console's URL under the site being read.
21
+ * The Emails console's URL: under the site being read, or the organization's
22
+ * own Emails page on a surface mounted over the org.
21
23
  *
22
24
  * A campaign's page links to two records it does not own — the message that
23
25
  * was sent inside it, and the template that message was built from — and both
@@ -30,14 +32,21 @@ import { useParams } from "next/navigation";
30
32
  * Free, deliberately. The org slug and the subdomain are already in the URL
31
33
  * the console is on, so this reads the route rather than resolving the host
32
34
  * document — the two `getDoc`s that resolution costs would be paid on every
33
- * open of a campaign, to render a link.
35
+ * open of a campaign, to render a link. At the organization level the org
36
+ * mount already carries the slug.
37
+ *
38
+ * The org answer comes from the MOUNT rather than from the route having no
39
+ * site in it: a card is on the organization's page because it was handed the
40
+ * org mount, and that is the fact a link to the org's messages has to follow.
41
+ * A message is the org's document, so its page there is
42
+ * `/[orgSlug]/emails/messages/{id}` — the Emails page over every site.
34
43
  *
35
44
  * `null` before the params resolve, so callers render plain text rather than
36
- * a link to nowhere — and on the org Marketing hub, where there is no site
37
- * and so no Emails console; a card there builds its message links from the
38
- * org mount instead.
45
+ * a link to nowhere.
39
46
  */ export function useEmailsHubPath() {
40
- return useConsoleHubPath('emails');
47
+ const orgMount = useMarketingOrgMount();
48
+ const siteHub = useConsoleHubPath(EMAILS_SLUG);
49
+ return orgMount ? orgEmailsHubPath(orgMount.orgSlug) : siteHub;
41
50
  }
42
51
  /**
43
52
  * The MARKETING console's URL: under the site being read, or the
@@ -48,9 +57,8 @@ import { useParams } from "next/navigation";
48
57
  * links to the campaign it came from, and those surfaces are handed their own
49
58
  * hub's `basePath`, not this one's.
50
59
  *
51
- * Unlike the Emails hub it has an answer with no site in the URL. Campaigns
52
- * belong to the organization, so the org-level CRM and the org Marketing hub
53
- * itself link a campaign to `/[orgSlug]/marketing/campaigns/{id}`.
60
+ * Campaigns belong to the organization, so the org-level CRM and the org
61
+ * Marketing hub itself link a campaign to `/[orgSlug]/marketing/campaigns/{id}`.
54
62
  */ export function useMarketingHubPath() {
55
63
  const params = useParams();
56
64
  const orgSlug = params == null ? void 0 : params.orgSlug;
@@ -59,12 +67,22 @@ import { useParams } from "next/navigation";
59
67
  orgSlug,
60
68
  host: params.host,
61
69
  pluginSlug: MARKETING_SLUG
62
- }) : buildRoute(Route.ORG_PLUGIN, {
70
+ }) : orgMarketingHubPath(orgSlug);
71
+ }
72
+ /** The Marketing hub's URL slug, under a site and under the org alike. */ const MARKETING_SLUG = 'marketing';
73
+ /** The Emails console's URL slug, under a site and under the org alike. */ const EMAILS_SLUG = 'emails';
74
+ /** The organization's own Marketing hub, `/[orgSlug]/marketing`. */ export function orgMarketingHubPath(orgSlug) {
75
+ return buildRoute(Route.ORG_PLUGIN, {
63
76
  orgSlug,
64
77
  pluginSlug: MARKETING_SLUG
65
78
  });
66
79
  }
67
- /** The Marketing hub's URL slug, under a site and under the org alike. */ const MARKETING_SLUG = 'marketing';
80
+ /** The organization's own Emails page, `/[orgSlug]/emails`. */ export function orgEmailsHubPath(orgSlug) {
81
+ return buildRoute(Route.ORG_PLUGIN, {
82
+ orgSlug,
83
+ pluginSlug: EMAILS_SLUG
84
+ });
85
+ }
68
86
  /**
69
87
  * Any sibling hub's URL under the site being read, by plugin slug.
70
88
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/use-emails-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'use client'\n\nimport { buildRoute, Route } from '@aglyn/aglyn'\nimport { useParams } from 'next/navigation'\n\n/**\n * The Emails console's URL under the site being read.\n *\n * A campaign's page links to two records it does not own — the message that\n * was sent inside it, and the template that message was built from — and both\n * keep their pages on the Emails console. The shell hands a plugin page only\n * its OWN `basePath`, so the sibling hub is named here by its SLUG rather\n * than by editing the last segment off the current path: a slug is a string\n * this file can be searched for when a surface moves, and string surgery on\n * somebody else's path is a link that keeps resolving to the wrong place.\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 campaign, to render a link.\n *\n * `null` before the params resolve, so callers render plain text rather than\n * a link to nowhere — and on the org Marketing hub, where there is no site\n * and so no Emails console; a card there builds its message links from the\n * org mount instead.\n */\nexport function useEmailsHubPath(): string | null {\n return useConsoleHubPath('emails')\n}\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 * Named for the same reason the Emails one is, from the opposite direction: a\n * converted record — a form submission in the Inbox, a contact in Contacts —\n * links to the campaign it came from, and those surfaces are handed their own\n * hub's `basePath`, not this one's.\n *\n * Unlike the Emails hub it has an answer with no site in the URL. Campaigns\n * belong to the organization, so the org-level CRM and the org Marketing hub\n * 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 : buildRoute(Route.ORG_PLUGIN, { orgSlug, pluginSlug: MARKETING_SLUG })\n}\n\n/** The Marketing hub's URL slug, under a site and under the org alike. */\nconst MARKETING_SLUG = 'marketing'\n\n/**\n * Any sibling hub's URL under the site being read, by plugin slug.\n *\n * One implementation, because \"read the org slug and the subdomain off the\n * route rather than resolving the host document\" is the property that keeps\n * these links free, and a second copy of it is where somebody pays for the\n * two `getDoc`s instead.\n *\n * `null` on a page that names no site: a site's hub is the only thing this\n * builds.\n */\nexport function useConsoleHubPath(pluginSlug: string): string | null {\n const params = useParams<{ orgSlug: string; host: string }>()\n const orgSlug = params?.orgSlug\n const host = params?.host\n if (!orgSlug || !host) return null\n return buildRoute(Route.HOST_PLUGIN, { orgSlug, host, pluginSlug })\n}\n\nexport default useEmailsHubPath\n"],"names":["buildRoute","Route","useParams","useEmailsHubPath","useConsoleHubPath","useMarketingHubPath","params","orgSlug","host","HOST_PLUGIN","pluginSlug","MARKETING_SLUG","ORG_PLUGIN"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAASA,UAAU,EAAEC,KAAK,QAAQ,eAAc;AAChD,SAASC,SAAS,QAAQ,kBAAiB;AAE3C;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASC;IACd,OAAOC,kBAAkB;AAC3B;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC;IACd,MAAMC,SAASJ;IACf,MAAMK,UAAUD,0BAAAA,OAAQC,OAAO;IAC/B,IAAI,CAACA,SAAS,OAAO;IACrB,OAAOD,CAAAA,0BAAAA,OAAQE,IAAI,IACfR,WAAWC,MAAMQ,WAAW,EAAE;QAC5BF;QACAC,MAAMF,OAAOE,IAAI;QACjBE,YAAYC;IACd,KACAX,WAAWC,MAAMW,UAAU,EAAE;QAAEL;QAASG,YAAYC;IAAe;AACzE;AAEA,wEAAwE,GACxE,MAAMA,iBAAiB;AAEvB;;;;;;;;;;CAUC,GACD,OAAO,SAASP,kBAAkBM,UAAkB;IAClD,MAAMJ,SAASJ;IACf,MAAMK,UAAUD,0BAAAA,OAAQC,OAAO;IAC/B,MAAMC,OAAOF,0BAAAA,OAAQE,IAAI;IACzB,IAAI,CAACD,WAAW,CAACC,MAAM,OAAO;IAC9B,OAAOR,WAAWC,MAAMQ,WAAW,EAAE;QAAEF;QAASC;QAAME;IAAW;AACnE;AAEA,eAAeP,iBAAgB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/use-emails-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'use client'\n\nimport { buildRoute, Route } from '@aglyn/aglyn'\nimport { useParams } from 'next/navigation'\nimport { useMarketingOrgMount } from './marketing-org-mount'\n\n/**\n * The Emails console's URL: under the site being read, or the organization's\n * own Emails page on a surface mounted over the org.\n *\n * A campaign's page links to two records it does not own — the message that\n * was sent inside it, and the template that message was built from — and both\n * keep their pages on the Emails console. The shell hands a plugin page only\n * its OWN `basePath`, so the sibling hub is named here by its SLUG rather\n * than by editing the last segment off the current path: a slug is a string\n * this file can be searched for when a surface moves, and string surgery on\n * somebody else's path is a link that keeps resolving to the wrong place.\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 campaign, to render a link. At the organization level the org\n * mount already carries the slug.\n *\n * The org answer comes from the MOUNT rather than from the route having no\n * site in it: a card is on the organization's page because it was handed the\n * org mount, and that is the fact a link to the org's messages has to follow.\n * A message is the org's document, so its page there is\n * `/[orgSlug]/emails/messages/{id}` — the Emails page over every site.\n *\n * `null` before the params resolve, so callers render plain text rather than\n * a link to nowhere.\n */\nexport function useEmailsHubPath(): string | null {\n const orgMount = useMarketingOrgMount()\n const siteHub = useConsoleHubPath(EMAILS_SLUG)\n return orgMount ? orgEmailsHubPath(orgMount.orgSlug) : siteHub\n}\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 * Named for the same reason the Emails one is, from the opposite direction: a\n * converted record — a form submission in the Inbox, a contact in Contacts —\n * links to the campaign it came from, and those surfaces are handed their own\n * hub's `basePath`, not this one's.\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 Emails console's URL slug, under a site and under the org alike. */\nconst EMAILS_SLUG = 'emails'\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\n/** The organization's own Emails page, `/[orgSlug]/emails`. */\nexport function orgEmailsHubPath(orgSlug: string): string {\n return buildRoute(Route.ORG_PLUGIN, { orgSlug, pluginSlug: EMAILS_SLUG })\n}\n\n/**\n * Any sibling hub's URL under the site being read, by plugin slug.\n *\n * One implementation, because \"read the org slug and the subdomain off the\n * route rather than resolving the host document\" is the property that keeps\n * these links free, and a second copy of it is where somebody pays for the\n * two `getDoc`s instead.\n *\n * `null` on a page that names no site: a site's hub is the only thing this\n * builds.\n */\nexport function useConsoleHubPath(pluginSlug: string): string | null {\n const params = useParams<{ orgSlug: string; host: string }>()\n const orgSlug = params?.orgSlug\n const host = params?.host\n if (!orgSlug || !host) return null\n return buildRoute(Route.HOST_PLUGIN, { orgSlug, host, pluginSlug })\n}\n\nexport default useEmailsHubPath\n"],"names":["buildRoute","Route","useParams","useMarketingOrgMount","useEmailsHubPath","orgMount","siteHub","useConsoleHubPath","EMAILS_SLUG","orgEmailsHubPath","orgSlug","useMarketingHubPath","params","host","HOST_PLUGIN","pluginSlug","MARKETING_SLUG","orgMarketingHubPath","ORG_PLUGIN"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAASA,UAAU,EAAEC,KAAK,QAAQ,eAAc;AAChD,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SAASC,oBAAoB,QAAQ,2BAAuB;AAE5D;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASC;IACd,MAAMC,WAAWF;IACjB,MAAMG,UAAUC,kBAAkBC;IAClC,OAAOH,WAAWI,iBAAiBJ,SAASK,OAAO,IAAIJ;AACzD;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASK;IACd,MAAMC,SAASV;IACf,MAAMQ,UAAUE,0BAAAA,OAAQF,OAAO;IAC/B,IAAI,CAACA,SAAS,OAAO;IACrB,OAAOE,CAAAA,0BAAAA,OAAQC,IAAI,IACfb,WAAWC,MAAMa,WAAW,EAAE;QAC5BJ;QACAG,MAAMD,OAAOC,IAAI;QACjBE,YAAYC;IACd,KACAC,oBAAoBP;AAC1B;AAEA,wEAAwE,GACxE,MAAMM,iBAAiB;AAEvB,yEAAyE,GACzE,MAAMR,cAAc;AAEpB,kEAAkE,GAClE,OAAO,SAASS,oBAAoBP,OAAe;IACjD,OAAOV,WAAWC,MAAMiB,UAAU,EAAE;QAAER;QAASK,YAAYC;IAAe;AAC5E;AAEA,6DAA6D,GAC7D,OAAO,SAASP,iBAAiBC,OAAe;IAC9C,OAAOV,WAAWC,MAAMiB,UAAU,EAAE;QAAER;QAASK,YAAYP;IAAY;AACzE;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASD,kBAAkBQ,UAAkB;IAClD,MAAMH,SAASV;IACf,MAAMQ,UAAUE,0BAAAA,OAAQF,OAAO;IAC/B,MAAMG,OAAOD,0BAAAA,OAAQC,IAAI;IACzB,IAAI,CAACH,WAAW,CAACG,MAAM,OAAO;IAC9B,OAAOb,WAAWC,MAAMa,WAAW,EAAE;QAAEJ;QAASG;QAAME;IAAW;AACnE;AAEA,eAAeX,iBAAgB"}
@@ -0,0 +1,55 @@
1
+ import { type DependencyList } from 'react';
2
+ export type OneShotStatus = 'loading' | 'success' | 'error';
3
+ /** A read as a card renders it: the answer once it arrives, `null` until then. */
4
+ export interface OneShotRead<T> {
5
+ value: T | null;
6
+ status: OneShotStatus;
7
+ }
8
+ /**
9
+ * One server read, run when `deps` change and never per render — and never
10
+ * as a listener.
11
+ *
12
+ * For the figures and snapshots the Overview and the org lists draw: a
13
+ * server aggregate is a promise, not a subscription, and an overlay's
14
+ * lifetime counters move on every impression the beacon records, so a
15
+ * listener over them would re-deliver a document per impression for as long
16
+ * as the page stayed open. A snapshot answers once.
17
+ *
18
+ * `read` returning `null` means "not yet" — the org is not known — and
19
+ * leaves the read pending rather than asking about nothing. The latest
20
+ * `read` is the one invoked, through a ref, so it may close over fresh
21
+ * props without listing each of them; `deps` names WHEN to read again.
22
+ */
23
+ export declare function useOneShotRead<T>(read: () => Promise<T> | null, deps: DependencyList): OneShotRead<T>;
24
+ /**
25
+ * How many sites one page of an org list reads.
26
+ *
27
+ * The org hub's per-site lists are paged by SITE rather than by row, because
28
+ * a site is what each read is made for: turning the page reads the next
29
+ * sites and nothing else, so a section costs the same for an organization of
30
+ * three sites or three hundred.
31
+ */
32
+ export declare const ORG_SITES_PER_PAGE = 10;
33
+ export interface OrgSiteReads<T> {
34
+ /** Each site asked for so far, by host id. A site not yet asked is absent. */
35
+ reads: ReadonlyMap<string, OneShotRead<T>>;
36
+ /**
37
+ * Rewrites one site's answer in place — the card's own record of a write
38
+ * it just made, so a switch it flipped does not flip back until the next
39
+ * read.
40
+ */
41
+ patch: (hostId: string, update: (value: T) => T) => void;
42
+ }
43
+ /**
44
+ * One read PER SITE, for the sites a card is drawing — the org hub's
45
+ * bounded fan-out.
46
+ *
47
+ * The caller decides which sites and how many: a page of them, or the first
48
+ * N. Only those are read. A site answered once stays answered while the card
49
+ * is mounted, so turning back to a page already seen costs nothing, and
50
+ * changing `deps` forgets every answer and asks again.
51
+ *
52
+ * Each site is read on its own and fails on its own: one site whose read is
53
+ * refused is one row that says so, not a card that draws nothing.
54
+ */
55
+ export declare function useOrgSiteReads<T>(hostIds: readonly string[], read: (hostId: string) => Promise<T>, deps: DependencyList): OrgSiteReads<T>;
@@ -0,0 +1,168 @@
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
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
+ /**
20
+ * One server read, run when `deps` change and never per render — and never
21
+ * as a listener.
22
+ *
23
+ * For the figures and snapshots the Overview and the org lists draw: a
24
+ * server aggregate is a promise, not a subscription, and an overlay's
25
+ * lifetime counters move on every impression the beacon records, so a
26
+ * listener over them would re-deliver a document per impression for as long
27
+ * as the page stayed open. A snapshot answers once.
28
+ *
29
+ * `read` returning `null` means "not yet" — the org is not known — and
30
+ * leaves the read pending rather than asking about nothing. The latest
31
+ * `read` is the one invoked, through a ref, so it may close over fresh
32
+ * props without listing each of them; `deps` names WHEN to read again.
33
+ */ export function useOneShotRead(read, deps) {
34
+ const [state, setState] = useState({
35
+ value: null,
36
+ status: 'loading'
37
+ });
38
+ const readRef = useRef(read);
39
+ readRef.current = read;
40
+ useEffect(()=>{
41
+ const pending = readRef.current();
42
+ setState({
43
+ value: null,
44
+ status: 'loading'
45
+ });
46
+ if (!pending) return undefined;
47
+ let active = true;
48
+ pending.then((value)=>{
49
+ if (active) setState({
50
+ value,
51
+ status: 'success'
52
+ });
53
+ }, ()=>{
54
+ if (active) setState({
55
+ value: null,
56
+ status: 'error'
57
+ });
58
+ });
59
+ return ()=>{
60
+ active = false;
61
+ };
62
+ // `deps` is the caller's list, forwarded the way `useFirestoreCollection`
63
+ // forwards its own.
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, deps);
66
+ return state;
67
+ }
68
+ /**
69
+ * How many sites one page of an org list reads.
70
+ *
71
+ * The org hub's per-site lists are paged by SITE rather than by row, because
72
+ * a site is what each read is made for: turning the page reads the next
73
+ * sites and nothing else, so a section costs the same for an organization of
74
+ * three sites or three hundred.
75
+ */ export const ORG_SITES_PER_PAGE = 10;
76
+ /**
77
+ * One read PER SITE, for the sites a card is drawing — the org hub's
78
+ * bounded fan-out.
79
+ *
80
+ * The caller decides which sites and how many: a page of them, or the first
81
+ * N. Only those are read. A site answered once stays answered while the card
82
+ * is mounted, so turning back to a page already seen costs nothing, and
83
+ * changing `deps` forgets every answer and asks again.
84
+ *
85
+ * Each site is read on its own and fails on its own: one site whose read is
86
+ * refused is one row that says so, not a card that draws nothing.
87
+ */ export function useOrgSiteReads(hostIds, read, deps) {
88
+ const [answers, setAnswers] = useState({});
89
+ const readRef = useRef(read);
90
+ readRef.current = read;
91
+ /** The sites asked for under the current `deps`, answered or not. */ const asked = useRef(new Set());
92
+ /**
93
+ * Bumped whenever the answers so far stop counting — `deps` changed, or
94
+ * the card unmounted — so a read still in flight from before lands
95
+ * nowhere.
96
+ */ const generation = useRef(0);
97
+ useEffect(()=>{
98
+ generation.current += 1;
99
+ asked.current = new Set();
100
+ setAnswers({});
101
+ return ()=>{
102
+ generation.current += 1;
103
+ };
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, deps);
106
+ // A string, so the effect below re-runs when the SET changes and not when
107
+ // the caller hands over a new array holding the same sites.
108
+ const wantedKey = hostIds.join('\n');
109
+ useEffect(()=>{
110
+ const wanted = wantedKey ? wantedKey.split('\n') : [];
111
+ const fresh = wanted.filter((hostId)=>!asked.current.has(hostId));
112
+ if (!fresh.length) return;
113
+ const current = generation.current;
114
+ for (const hostId of fresh)asked.current.add(hostId);
115
+ setAnswers((previous)=>{
116
+ const next = _extends({}, previous);
117
+ for (const hostId of fresh)next[hostId] = {
118
+ value: null,
119
+ status: 'loading'
120
+ };
121
+ return next;
122
+ });
123
+ for (const hostId of fresh){
124
+ readRef.current(hostId).then((value)=>{
125
+ if (generation.current !== current) return;
126
+ setAnswers((previous)=>_extends({}, previous, {
127
+ [hostId]: {
128
+ value,
129
+ status: 'success'
130
+ }
131
+ }));
132
+ }, ()=>{
133
+ if (generation.current !== current) return;
134
+ setAnswers((previous)=>_extends({}, previous, {
135
+ [hostId]: {
136
+ value: null,
137
+ status: 'error'
138
+ }
139
+ }));
140
+ });
141
+ }
142
+ // `deps` again, so a change reads the wanted sites afresh once the
143
+ // effect above has forgotten them.
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [
146
+ wantedKey,
147
+ ...deps
148
+ ]);
149
+ const patch = useCallback((hostId, update)=>setAnswers((previous)=>{
150
+ const answer = previous[hostId];
151
+ if (!answer || answer.value === null) return previous;
152
+ return _extends({}, previous, {
153
+ [hostId]: {
154
+ value: update(answer.value),
155
+ status: answer.status
156
+ }
157
+ });
158
+ }), []);
159
+ const reads = useMemo(()=>new Map(Object.entries(answers)), [
160
+ answers
161
+ ]);
162
+ return {
163
+ reads,
164
+ patch
165
+ };
166
+ }
167
+
168
+ //# sourceMappingURL=use-one-shot-reads.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/components/use-one-shot-reads.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'use client'\n\nimport {\n type DependencyList,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react'\n\nexport type OneShotStatus = 'loading' | 'success' | 'error'\n\n/** A read as a card renders it: the answer once it arrives, `null` until then. */\nexport interface OneShotRead<T> {\n value: T | null\n status: OneShotStatus\n}\n\n/**\n * One server read, run when `deps` change and never per render — and never\n * as a listener.\n *\n * For the figures and snapshots the Overview and the org lists draw: a\n * server aggregate is a promise, not a subscription, and an overlay's\n * lifetime counters move on every impression the beacon records, so a\n * listener over them would re-deliver a document per impression for as long\n * as the page stayed open. A snapshot answers once.\n *\n * `read` returning `null` means \"not yet\" — the org is not known — and\n * leaves the read pending rather than asking about nothing. The latest\n * `read` is the one invoked, through a ref, so it may close over fresh\n * props without listing each of them; `deps` names WHEN to read again.\n */\nexport function useOneShotRead<T>(\n read: () => Promise<T> | null,\n deps: DependencyList,\n): OneShotRead<T> {\n const [state, setState] = useState<OneShotRead<T>>({\n value: null,\n status: 'loading',\n })\n const readRef = useRef(read)\n readRef.current = read\n\n useEffect(() => {\n const pending = readRef.current()\n setState({ value: null, status: 'loading' })\n if (!pending) return undefined\n let active = true\n pending.then(\n (value) => {\n if (active) setState({ value, status: 'success' })\n },\n () => {\n if (active) setState({ value: null, status: 'error' })\n },\n )\n return () => {\n active = false\n }\n // `deps` is the caller's list, forwarded the way `useFirestoreCollection`\n // forwards its own.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return state\n}\n\n/**\n * How many sites one page of an org list reads.\n *\n * The org hub's per-site lists are paged by SITE rather than by row, because\n * a site is what each read is made for: turning the page reads the next\n * sites and nothing else, so a section costs the same for an organization of\n * three sites or three hundred.\n */\nexport const ORG_SITES_PER_PAGE = 10\n\nexport interface OrgSiteReads<T> {\n /** Each site asked for so far, by host id. A site not yet asked is absent. */\n reads: ReadonlyMap<string, OneShotRead<T>>\n /**\n * Rewrites one site's answer in place — the card's own record of a write\n * it just made, so a switch it flipped does not flip back until the next\n * read.\n */\n patch: (hostId: string, update: (value: T) => T) => void\n}\n\n/**\n * One read PER SITE, for the sites a card is drawing — the org hub's\n * bounded fan-out.\n *\n * The caller decides which sites and how many: a page of them, or the first\n * N. Only those are read. A site answered once stays answered while the card\n * is mounted, so turning back to a page already seen costs nothing, and\n * changing `deps` forgets every answer and asks again.\n *\n * Each site is read on its own and fails on its own: one site whose read is\n * refused is one row that says so, not a card that draws nothing.\n */\nexport function useOrgSiteReads<T>(\n hostIds: readonly string[],\n read: (hostId: string) => Promise<T>,\n deps: DependencyList,\n): OrgSiteReads<T> {\n const [answers, setAnswers] = useState<Record<string, OneShotRead<T>>>({})\n const readRef = useRef(read)\n readRef.current = read\n /** The sites asked for under the current `deps`, answered or not. */\n const asked = useRef(new Set<string>())\n /**\n * Bumped whenever the answers so far stop counting — `deps` changed, or\n * the card unmounted — so a read still in flight from before lands\n * nowhere.\n */\n const generation = useRef(0)\n\n useEffect(() => {\n generation.current += 1\n asked.current = new Set()\n setAnswers({})\n return () => {\n generation.current += 1\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n // A string, so the effect below re-runs when the SET changes and not when\n // the caller hands over a new array holding the same sites.\n const wantedKey = hostIds.join('\\n')\n\n useEffect(() => {\n const wanted = wantedKey ? wantedKey.split('\\n') : []\n const fresh = wanted.filter((hostId) => !asked.current.has(hostId))\n if (!fresh.length) return\n const current = generation.current\n for (const hostId of fresh) asked.current.add(hostId)\n setAnswers((previous) => {\n const next = { ...previous }\n for (const hostId of fresh) next[hostId] = { value: null, status: 'loading' }\n return next\n })\n for (const hostId of fresh) {\n readRef.current(hostId).then(\n (value) => {\n if (generation.current !== current) return\n setAnswers((previous) => ({\n ...previous,\n [hostId]: { value, status: 'success' },\n }))\n },\n () => {\n if (generation.current !== current) return\n setAnswers((previous) => ({\n ...previous,\n [hostId]: { value: null, status: 'error' },\n }))\n },\n )\n }\n // `deps` again, so a change reads the wanted sites afresh once the\n // effect above has forgotten them.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [wantedKey, ...deps])\n\n const patch = useCallback(\n (hostId: string, update: (value: T) => T) =>\n setAnswers((previous) => {\n const answer = previous[hostId]\n if (!answer || answer.value === null) return previous\n return {\n ...previous,\n [hostId]: { value: update(answer.value), status: answer.status },\n }\n }),\n [],\n )\n\n const reads = useMemo(() => new Map(Object.entries(answers)), [answers])\n return { reads, patch }\n}\n"],"names":["useCallback","useEffect","useMemo","useRef","useState","useOneShotRead","read","deps","state","setState","value","status","readRef","current","pending","undefined","active","then","ORG_SITES_PER_PAGE","useOrgSiteReads","hostIds","answers","setAnswers","asked","Set","generation","wantedKey","join","wanted","split","fresh","filter","hostId","has","length","add","previous","next","patch","update","answer","reads","Map","Object","entries"],"mappings":"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"}
@@ -113,6 +113,41 @@ export declare function compareVariants(control: {
113
113
  exposures?: number;
114
114
  conversions?: number;
115
115
  }): VariantComparison;
116
+ /** Per-variant counters, keyed by variant id, as `experiments/{id}/stats` holds them. */
117
+ export type ExperimentStatsByVariant = Record<string, {
118
+ exposures?: number;
119
+ conversions?: number;
120
+ } | undefined>;
121
+ /** One variant's line in the results table. */
122
+ export interface ExperimentResultRow {
123
+ variant: ExperimentVariant;
124
+ summary: {
125
+ exposures: number;
126
+ conversions: number;
127
+ rate: number;
128
+ };
129
+ /**
130
+ * Lift and confidence against the control — the FIRST variant, which is
131
+ * compared against nothing and so carries `null` here.
132
+ */
133
+ comparison: VariantComparison | null;
134
+ /** It has exposures and no variant converts at a higher rate. Ties all lead. */
135
+ leader: boolean;
136
+ /** The variant the experiment finished on. */
137
+ winner: boolean;
138
+ }
139
+ /**
140
+ * The results table's rows, in variant order: each variant's figures, its
141
+ * comparison with the control (AGL-265), and whether it leads or won.
142
+ */
143
+ export declare function experimentResultRows(experiment: Pick<HostExperiment, 'variants' | 'winnerVariantId'>, stats: ExperimentStatsByVariant): ExperimentResultRow[];
144
+ /**
145
+ * A comparison as the results table prints it: `+12% · 97% conf.`, a dash
146
+ * for a lift the control's zero rate cannot express, `needs data` until the
147
+ * z-test has something to test, and `control` for the variant everything is
148
+ * measured against.
149
+ */
150
+ export declare function describeVariantComparison(comparison: VariantComparison | null): string;
116
151
  /**
117
152
  * Auto-winner evaluation (AGL-273): decides a running experiment from
118
153
  * per-variant stats once thresholds are met. The first variant is the
@@ -142,6 +142,40 @@
142
142
  confidence: normalCdf(z)
143
143
  };
144
144
  }
145
+ /**
146
+ * The results table's rows, in variant order: each variant's figures, its
147
+ * comparison with the control (AGL-265), and whether it leads or won.
148
+ */ export function experimentResultRows(experiment, stats) {
149
+ var _experiment_variants;
150
+ const variants = (_experiment_variants = experiment.variants) != null ? _experiment_variants : [];
151
+ const summaries = variants.map((variant)=>{
152
+ var _stats_variant_id;
153
+ return summarizeVariantStats((_stats_variant_id = stats[variant.id]) != null ? _stats_variant_id : {});
154
+ });
155
+ const control = variants[0];
156
+ return variants.map((variant, index)=>{
157
+ var _stats_control_id, _stats_variant_id;
158
+ const summary = summaries[index];
159
+ return {
160
+ variant,
161
+ summary,
162
+ comparison: index > 0 && control ? compareVariants((_stats_control_id = stats[control.id]) != null ? _stats_control_id : {}, (_stats_variant_id = stats[variant.id]) != null ? _stats_variant_id : {}) : null,
163
+ leader: summary.exposures > 0 && summaries.every((other)=>summary.rate >= other.rate),
164
+ winner: experiment.winnerVariantId === variant.id
165
+ };
166
+ });
167
+ }
168
+ /**
169
+ * A comparison as the results table prints it: `+12% · 97% conf.`, a dash
170
+ * for a lift the control's zero rate cannot express, `needs data` until the
171
+ * z-test has something to test, and `control` for the variant everything is
172
+ * measured against.
173
+ */ export function describeVariantComparison(comparison) {
174
+ if (!comparison) return 'control';
175
+ const lift = comparison.lift != null ? `${comparison.lift >= 0 ? '+' : ''}${(comparison.lift * 100).toFixed(0)}%` : '—';
176
+ const confidence = comparison.confidence != null ? ` · ${(comparison.confidence * 100).toFixed(0)}% conf.` : ' · needs data';
177
+ return `${lift}${confidence}`;
178
+ }
145
179
  /**
146
180
  * Auto-winner evaluation (AGL-273): decides a running experiment from
147
181
  * per-variant stats once thresholds are met. The first variant is the
@@ -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/**\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","evaluateAutoWinner","statsByVariant","controlStats","config","best","everyLoses","challengerStats","variantId"],"mappings":"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"}
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":"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"}
@@ -61,10 +61,21 @@ export declare function overlayPatternMatches(pattern: string, path: string): bo
61
61
  export declare function overlayMatchesPath(overlay: Pick<HostOverlay, 'pathPatterns' | 'excludePathPatterns'>, path: string): boolean;
62
62
  /** True when `nowMs` falls inside the overlay's schedule window. */
63
63
  export declare function overlayActiveAt(overlay: Pick<HostOverlay, 'startAtMs' | 'endAtMs'>, nowMs: number): boolean;
64
+ /**
65
+ * Whether an overlay is switched off, switched on but outside its window, or
66
+ * showing now — the status every overlay list draws as a chip.
67
+ */
68
+ export declare function overlayStatus(overlay: Pick<HostOverlay, 'enabled' | 'startAtMs' | 'endAtMs'>, nowMs: number): 'off' | 'scheduled' | 'live';
69
+ /**
70
+ * The precedence overlays take when several match a page: `order`, then
71
+ * name. Only the console's reorder arrows ever write `order`, so most
72
+ * overlays share its default of 0 and fall through to their names.
73
+ */
74
+ export declare function compareOverlayPrecedence(a: Pick<HostOverlay, 'order' | 'name'>, b: Pick<HostOverlay, 'order' | 'name'>): number;
64
75
  /**
65
76
  * Picks the overlays to render for a page: enabled, inside their window,
66
- * targeting the path — sorted by `order` then name, first of each kind
67
- * wins (one bar + one popup per page render).
77
+ * targeting the path — sorted by {@link compareOverlayPrecedence}, first of
78
+ * each kind wins (one bar + one popup per page render).
68
79
  */
69
80
  export declare function resolveActiveOverlays(overlays: Array<HostOverlay & {
70
81
  $id?: string;