@delmaredigital/payload-experiments 0.2.0-rc.4 → 0.2.0-rc.6

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 (39) hide show
  1. package/README.md +7 -0
  2. package/dist/admin/PreviewLinks.js +8 -4
  3. package/dist/admin/TestsDashboardCards.d.ts +3 -0
  4. package/dist/admin/TestsDashboardCards.js +52 -0
  5. package/dist/admin/TestsNavLink.d.ts +2 -1
  6. package/dist/admin/TestsNavLink.js +61 -18
  7. package/dist/admin/copy/review.d.ts +1 -0
  8. package/dist/admin/copy/review.js +1 -0
  9. package/dist/admin/copy/surface.d.ts +4 -0
  10. package/dist/admin/copy/surface.js +4 -0
  11. package/dist/admin/dashboard.css +35 -0
  12. package/dist/admin/paths.d.ts +1 -0
  13. package/dist/admin/paths.js +1 -0
  14. package/dist/admin/previewLinks.model.d.ts +9 -0
  15. package/dist/admin/previewLinks.model.js +34 -0
  16. package/dist/admin/theme.css +2 -0
  17. package/dist/collections/events.js +1 -0
  18. package/dist/collections/identities.js +1 -0
  19. package/dist/collections/layers.js +1 -0
  20. package/dist/collections/metrics.js +1 -0
  21. package/dist/collections/phases.js +1 -0
  22. package/dist/collections/snapshots.js +1 -0
  23. package/dist/constants.d.ts +1 -0
  24. package/dist/constants.js +1 -0
  25. package/dist/endpoints/recompute.js +6 -1
  26. package/dist/exports/adminClient.d.ts +1 -0
  27. package/dist/exports/adminClient.js +1 -0
  28. package/dist/globals/state.js +1 -0
  29. package/dist/plugin.js +8 -2
  30. package/dist/puck/copy.d.ts +6 -0
  31. package/dist/puck/copy.js +7 -0
  32. package/dist/puck/editor/ExperimentSlotEditor.js +2 -0
  33. package/dist/puck/slotConfig.js +14 -9
  34. package/dist/results/analyse.d.ts +1 -1
  35. package/dist/results/analyse.js +7 -3
  36. package/dist/server/runResults.d.ts +1 -0
  37. package/dist/server/runResults.js +4 -0
  38. package/dist/types/endpoints.d.ts +1 -1
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -466,6 +466,13 @@ for these patterns.
466
466
 
467
467
  ## Admin and client
468
468
 
469
+ The sidebar includes a **Tests** group with **All tests** and **Goals**. The
470
+ dashboard also includes **Tests** and **Goals** cards above its widgets. Goals
471
+ appears only for users who can manage tests. Both additions follow the host's
472
+ admin route and keep host-provided navigation and dashboard entries first.
473
+ After upgrading, run `payload generate:importmap` to register the dashboard card
474
+ component.
475
+
469
476
  ### Client assignments and exposure
470
477
 
471
478
  Read assignments on the server and pass them to `ExperimentsProvider` from
@@ -6,6 +6,7 @@ import { armLabel } from './labels.js';
6
6
  import { useApi } from './useApi.js';
7
7
  import { REVIEW_COPY as C } from './copy/review.js';
8
8
  import { API_MESSAGES } from './copy/common.js';
9
+ import { previewPages } from './previewLinks.model.js';
9
10
  export function PreviewLinks({ experimentId, placements, arms }) {
10
11
  const api = useApi();
11
12
  const inFlight = useRef(false);
@@ -39,10 +40,13 @@ export function PreviewLinks({ experimentId, placements, arms }) {
39
40
  return /*#__PURE__*/ _jsxs("div", {
40
41
  className: "xp-preview-links",
41
42
  children: [
42
- placements.map((placement)=>/*#__PURE__*/ _jsxs("div", {
43
+ previewPages(placements).map((placement)=>/*#__PURE__*/ _jsxs("div", {
43
44
  children: [
44
- /*#__PURE__*/ _jsx("p", {
45
- children: placement.title
45
+ /*#__PURE__*/ _jsxs("p", {
46
+ children: [
47
+ placement.title,
48
+ placement.slotCount > 1 ? ` ${C.placedTimes(placement.slotCount)}` : null
49
+ ]
46
50
  }),
47
51
  placement.url ? arms.filter((arm)=>arm.kind !== 'holdout' && arm.key !== 'holdout').map((arm)=>/*#__PURE__*/ _jsxs(Button, {
48
52
  disabled: busy,
@@ -59,7 +63,7 @@ export function PreviewLinks({ experimentId, placements, arms }) {
59
63
  children: C.noPreview
60
64
  })
61
65
  ]
62
- }, `${placement.collection}/${placement.id}`)),
66
+ }, placement.key)),
63
67
  error ? /*#__PURE__*/ _jsx("p", {
64
68
  role: "alert",
65
69
  children: error
@@ -0,0 +1,3 @@
1
+ import './theme.css';
2
+ import './dashboard.css';
3
+ export declare function TestsDashboardCards(): import("react").JSX.Element | null;
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import './theme.css';
4
+ import './dashboard.css';
5
+ import { Card, useAuth, useConfig } from '@payloadcms/ui';
6
+ import { formatAdminURL } from 'payload/shared';
7
+ import { SURFACE_COPY } from './copy/surface.js';
8
+ export function TestsDashboardCards() {
9
+ const { config } = useConfig();
10
+ const { permissions } = useAuth();
11
+ const testPermissions = permissions?.collections?.['xp-experiments'];
12
+ const canManage = testPermissions?.update === true;
13
+ if (!canManage) return null;
14
+ const href = (path)=>formatAdminURL({
15
+ adminRoute: config.routes.admin,
16
+ path
17
+ });
18
+ return /*#__PURE__*/ _jsxs("section", {
19
+ className: "xp-dash-group",
20
+ "aria-labelledby": "xp-dash-title",
21
+ children: [
22
+ /*#__PURE__*/ _jsx("h2", {
23
+ className: "xp-dash-label",
24
+ id: "xp-dash-title",
25
+ children: SURFACE_COPY.title
26
+ }),
27
+ /*#__PURE__*/ _jsxs("ul", {
28
+ className: "xp-dash-card-list",
29
+ children: [
30
+ /*#__PURE__*/ _jsx("li", {
31
+ children: /*#__PURE__*/ _jsx(Card, {
32
+ title: SURFACE_COPY.navAllTests,
33
+ titleAs: "h3",
34
+ buttonAriaLabel: SURFACE_COPY.dashShowTests,
35
+ href: href('/experiments'),
36
+ id: "card-xp-tests"
37
+ })
38
+ }),
39
+ /*#__PURE__*/ _jsx("li", {
40
+ children: /*#__PURE__*/ _jsx(Card, {
41
+ title: SURFACE_COPY.navGoals,
42
+ titleAs: "h3",
43
+ buttonAriaLabel: SURFACE_COPY.dashShowGoals,
44
+ href: href('/experiments/goals'),
45
+ id: "card-xp-goals"
46
+ })
47
+ })
48
+ ]
49
+ })
50
+ ]
51
+ });
52
+ }
@@ -1,2 +1,3 @@
1
1
  import './theme.css';
2
- export declare function TestsNavLink(): import("react").JSX.Element;
2
+ declare function TestsNav(): import("react").JSX.Element | null;
3
+ export { TestsNav as TestsNavLink };
@@ -1,31 +1,74 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import './theme.css';
4
- import { Link, useConfig } from '@payloadcms/ui';
4
+ import { Link, NavGroup, useAuth, useConfig } from '@payloadcms/ui';
5
5
  import { formatAdminURL } from 'payload/shared';
6
6
  import { usePathname } from 'next/navigation.js';
7
7
  import { SURFACE_COPY } from './copy/surface.js';
8
- export function TestsNavLink() {
8
+ function TestsNav() {
9
9
  const { config } = useConfig();
10
- const path = usePathname();
10
+ const { permissions } = useAuth();
11
+ const pathname = usePathname();
11
12
  const href = formatAdminURL({
12
13
  adminRoute: config.routes.admin,
13
14
  path: '/experiments'
14
15
  });
16
+ const goalsHref = formatAdminURL({
17
+ adminRoute: config.routes.admin,
18
+ path: '/experiments/goals'
19
+ });
15
20
  const base = href.replace(/\/$/, '');
16
- const active = path === base || path?.startsWith(`${base}/`);
17
- return /*#__PURE__*/ _jsxs(Link, {
18
- href: href,
19
- className: "nav__link",
20
- "aria-current": active ? 'page' : undefined,
21
- children: [
22
- active ? /*#__PURE__*/ _jsx("div", {
23
- className: "nav__link-indicator"
24
- }) : null,
25
- /*#__PURE__*/ _jsx("span", {
26
- className: "nav__link-label",
27
- children: SURFACE_COPY.title
28
- })
29
- ]
21
+ const goalsBase = goalsHref.replace(/\/$/, '');
22
+ const goalsActive = pathname === goalsBase || Boolean(pathname?.startsWith(`${goalsBase}/`));
23
+ const testsActive = !goalsActive && (pathname === base || Boolean(pathname?.startsWith(`${base}/`)));
24
+ const testPermissions = permissions?.collections?.['xp-experiments'];
25
+ const canManage = testPermissions?.update === true;
26
+ if (!canManage) return null;
27
+ const entries = [
28
+ {
29
+ id: 'nav-xp-tests',
30
+ href,
31
+ label: SURFACE_COPY.navAllTests,
32
+ active: testsActive
33
+ },
34
+ {
35
+ id: 'nav-xp-goals',
36
+ href: goalsHref,
37
+ label: SURFACE_COPY.navGoals,
38
+ active: goalsActive
39
+ }
40
+ ];
41
+ // getPreference is async and can fetch; let NavGroup default to open.
42
+ return /*#__PURE__*/ _jsx(NavGroup, {
43
+ label: SURFACE_COPY.title,
44
+ children: entries.map((entry)=>{
45
+ const props = {
46
+ className: 'nav__link',
47
+ id: entry.id,
48
+ 'aria-current': entry.active ? 'page' : undefined
49
+ };
50
+ const content = /*#__PURE__*/ _jsxs(_Fragment, {
51
+ children: [
52
+ entry.active ? /*#__PURE__*/ _jsx("div", {
53
+ className: "nav__link-indicator"
54
+ }) : null,
55
+ /*#__PURE__*/ _jsx("span", {
56
+ className: "nav__link-label",
57
+ children: entry.label
58
+ })
59
+ ]
60
+ });
61
+ return pathname === entry.href ? /*#__PURE__*/ _jsx("div", {
62
+ ...props,
63
+ children: content
64
+ }, entry.id) : /*#__PURE__*/ _jsx(Link, {
65
+ ...props,
66
+ href: entry.href,
67
+ prefetch: false,
68
+ children: content
69
+ }, entry.id);
70
+ })
30
71
  });
31
72
  }
73
+ // Preserve the existing import-map key.
74
+ export { TestsNav as TestsNavLink };
@@ -1,6 +1,7 @@
1
1
  export declare const REVIEW_COPY: {
2
2
  readonly noBaseline: "No data yet for this metric.";
3
3
  readonly preview: "Preview";
4
+ readonly placedTimes: (n: number) => string;
4
5
  readonly noPreview: "Preview isn't set up for this page type";
5
6
  readonly raise: "Raise allocation";
6
7
  readonly addMetric: "Add a metric";
@@ -1,6 +1,7 @@
1
1
  export const REVIEW_COPY = {
2
2
  noBaseline: 'No data yet for this metric.',
3
3
  preview: 'Preview',
4
+ placedTimes: (n)=>`(placed ${n} times on this page)`,
4
5
  noPreview: "Preview isn't set up for this page type",
5
6
  raise: 'Raise allocation',
6
7
  addMetric: 'Add a metric',
@@ -3,6 +3,10 @@ export declare const SURFACE_COPY: {
3
3
  readonly newTest: "New test";
4
4
  readonly goals: "Goals";
5
5
  readonly search: "Search tests";
6
+ readonly navAllTests: "All tests";
7
+ readonly navGoals: "Goals";
8
+ readonly dashShowTests: "Show all tests";
9
+ readonly dashShowGoals: "Show all goals";
6
10
  readonly backToAdmin: "Back to admin";
7
11
  readonly limitedList: "Showing the 200 most recent tests.";
8
12
  readonly notInstalled: "The Tests screen isn't installed yet. Run `payload generate:importmap` and reload.";
@@ -3,6 +3,10 @@ export const SURFACE_COPY = {
3
3
  newTest: 'New test',
4
4
  goals: 'Goals',
5
5
  search: 'Search tests',
6
+ navAllTests: 'All tests',
7
+ navGoals: 'Goals',
8
+ dashShowTests: 'Show all tests',
9
+ dashShowGoals: 'Show all goals',
6
10
  backToAdmin: 'Back to admin',
7
11
  limitedList: 'Showing the 200 most recent tests.',
8
12
  notInstalled: "The Tests screen isn't installed yet. Run `payload generate:importmap` and reload.",
@@ -0,0 +1,35 @@
1
+ .xp-dash-group {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--xp-space-4);
5
+ margin-bottom: var(--xp-space-4);
6
+ }
7
+
8
+ .xp-dash-label { margin: 0; }
9
+
10
+ .xp-dash-card-list {
11
+ padding: 0;
12
+ margin: 0;
13
+ list-style: none;
14
+ display: grid;
15
+ grid-template-columns: repeat(5, 1fr);
16
+ gap: var(--xp-dash-card-gap);
17
+ }
18
+
19
+ .xp-dash-card-list .card { height: 100%; }
20
+
21
+ @media (max-width: 1440px) {
22
+ .xp-dash-card-list { grid-template-columns: repeat(4, 1fr); }
23
+ }
24
+
25
+ @media (max-width: 1024px) {
26
+ .xp-dash-card-list { grid-template-columns: repeat(2, 1fr); }
27
+ }
28
+
29
+ @media (max-width: 768px) {
30
+ .xp-dash-card-list { gap: var(--xp-dash-card-gap-small); }
31
+ }
32
+
33
+ @media (max-width: 400px) {
34
+ .xp-dash-card-list { grid-template-columns: repeat(1, 1fr); }
35
+ }
@@ -6,3 +6,4 @@ export declare const XP_TESTS_LIST_REDIRECT = "@delmaredigital/payload-experimen
6
6
  export declare const XP_TEST_EDIT_REDIRECT = "@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect";
7
7
  export declare const XP_TESTS_NAV_LINK = "@delmaredigital/payload-experiments/admin/client#TestsNavLink";
8
8
  export declare const XP_TESTS_SURFACE = "@delmaredigital/payload-experiments/admin/client#TestsSurface";
9
+ export declare const XP_TESTS_DASHBOARD_CARDS = "@delmaredigital/payload-experiments/admin/client#TestsDashboardCards";
@@ -6,3 +6,4 @@ export const XP_TESTS_LIST_REDIRECT = '@delmaredigital/payload-experiments/admin
6
6
  export const XP_TEST_EDIT_REDIRECT = '@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect';
7
7
  export const XP_TESTS_NAV_LINK = '@delmaredigital/payload-experiments/admin/client#TestsNavLink';
8
8
  export const XP_TESTS_SURFACE = '@delmaredigital/payload-experiments/admin/client#TestsSurface';
9
+ export const XP_TESTS_DASHBOARD_CARDS = '@delmaredigital/payload-experiments/admin/client#TestsDashboardCards';
@@ -0,0 +1,9 @@
1
+ import type { PreflightResult } from '../types/endpoints.js';
2
+ type Placement = PreflightResult['placements'][number];
3
+ type PreviewPage = Pick<Placement, 'collection' | 'id' | 'title' | 'url'> & {
4
+ key: string;
5
+ slotCount: number;
6
+ };
7
+ /** Preview URLs identify pages; counts use the most placements in any one version. */
8
+ export declare function previewPages(placements: readonly Placement[]): PreviewPage[];
9
+ export {};
@@ -0,0 +1,34 @@
1
+ /** Preview URLs identify pages; counts use the most placements in any one version. */ export function previewPages(placements) {
2
+ const pages = new Map();
3
+ for (const placement of placements){
4
+ const key = `${placement.collection}\u0000${String(placement.id)}`;
5
+ const existing = pages.get(key);
6
+ if (existing) {
7
+ const { page, versions } = existing;
8
+ const count = (versions.get(placement.detail) ?? 0) + 1;
9
+ versions.set(placement.detail, count);
10
+ page.slotCount = Math.max(page.slotCount, count);
11
+ if (!page.url && placement.url) page.url = placement.url;
12
+ } else {
13
+ pages.set(key, {
14
+ page: {
15
+ key,
16
+ collection: placement.collection,
17
+ id: placement.id,
18
+ title: placement.title,
19
+ url: placement.url,
20
+ slotCount: 1
21
+ },
22
+ versions: new Map([
23
+ [
24
+ placement.detail,
25
+ 1
26
+ ]
27
+ ])
28
+ });
29
+ }
30
+ }
31
+ return [
32
+ ...pages.values()
33
+ ].map(({ page })=>page);
34
+ }
@@ -36,6 +36,8 @@
36
36
  --xp-space-4: calc(var(--base) * 1);
37
37
  --xp-space-5: calc(var(--base) * 1.5);
38
38
  --xp-space-6: calc(var(--base) * 2);
39
+ --xp-dash-card-gap: 12px;
40
+ --xp-dash-card-gap-small: calc(var(--base) * 0.4);
39
41
  --xp-font-body: var(--font-body);
40
42
  --xp-font-size-small: calc(var(--base) * 0.65);
41
43
  --xp-font-size-body: calc(var(--base) * 0.75);
@@ -4,6 +4,7 @@ export function eventsCollection(options) {
4
4
  slug: 'xp-events',
5
5
  graphQL: false,
6
6
  disableDuplicate: true,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
@@ -4,6 +4,7 @@ export function identitiesCollection(options) {
4
4
  slug: 'xp-identities',
5
5
  graphQL: false,
6
6
  disableDuplicate: true,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
@@ -12,6 +12,7 @@ export function layersCollection(options) {
12
12
  slug: 'xp-layers',
13
13
  graphQL: false,
14
14
  disableDuplicate: true,
15
+ versions: false,
15
16
  // group:false hides navigation while keeping collection routes reachable (hidden causes 404s).
16
17
  labels: {
17
18
  singular: 'Test group',
@@ -8,6 +8,7 @@ export function metricsCollection(options) {
8
8
  slug: 'xp-metrics',
9
9
  graphQL: false,
10
10
  disableDuplicate: true,
11
+ versions: false,
11
12
  // group:false hides navigation while keeping collection routes reachable (hidden causes 404s).
12
13
  labels: {
13
14
  singular: 'Goal',
@@ -4,6 +4,7 @@ export function phasesCollection(options) {
4
4
  slug: 'xp-phases',
5
5
  graphQL: false,
6
6
  disableDuplicate: true,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
@@ -4,6 +4,7 @@ export function snapshotsCollection(options) {
4
4
  slug: 'xp-results-snapshots',
5
5
  graphQL: false,
6
6
  disableDuplicate: true,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
@@ -1,3 +1,4 @@
1
+ export declare const DEFAULT_SEQUENTIAL_TUNING_PARAMETER = 5000;
1
2
  export declare const MAX_EVENTS_PER_BODY = 20;
2
3
  export declare const DEFAULT_MAX_BODY_BYTES = 8192;
3
4
  export declare const XP_COOKIE = "xp_id";
package/dist/constants.js CHANGED
@@ -1,3 +1,4 @@
1
+ export const DEFAULT_SEQUENTIAL_TUNING_PARAMETER = 5000;
1
2
  export const MAX_EVENTS_PER_BODY = 20;
2
3
  export const DEFAULT_MAX_BODY_BYTES = 8192;
3
4
  export const XP_COOKIE = 'xp_id';
@@ -22,6 +22,7 @@ export async function handleRecompute(req, _ctx) {
22
22
  experimentId: docs[0].id,
23
23
  sweep: false
24
24
  });
25
+ if (result.failed) return resultsError(500, 'analysis-failed', 'Results could not be computed; this has been logged.');
25
26
  if (result.skipped || result.budgetExhausted) return resultsError(503, 'recompute-failed', 'Results could not be recomputed. Try again.');
26
27
  const latest = (await readSnapshots(req, [
27
28
  docs[0].id
@@ -36,7 +37,11 @@ export async function handleRecompute(req, _ctx) {
36
37
  'Cache-Control': 'no-store'
37
38
  }
38
39
  });
39
- } catch {
40
+ } catch (error) {
41
+ req.payload.logger.error({
42
+ err: error,
43
+ experimentId: docs[0].id
44
+ }, 'Experiment recompute failed');
40
45
  return resultsError(503, 'recompute-failed', 'Results could not be recomputed. Try again.');
41
46
  }
42
47
  }
@@ -3,3 +3,4 @@ export { MetricSourceSelect } from '../admin/MetricSourceSelect.js';
3
3
  export { ArmSelectField } from '../admin/armSelect/ArmSelectField.js';
4
4
  export { TestsNavLink } from '../admin/TestsNavLink.js';
5
5
  export { TestsSurface } from '../admin/surface/TestsSurface.js';
6
+ export { TestsDashboardCards } from '../admin/TestsDashboardCards.js';
@@ -4,3 +4,4 @@ export { MetricSourceSelect } from '../admin/MetricSourceSelect.js';
4
4
  export { ArmSelectField } from '../admin/armSelect/ArmSelectField.js';
5
5
  export { TestsNavLink } from '../admin/TestsNavLink.js';
6
6
  export { TestsSurface } from '../admin/surface/TestsSurface.js';
7
+ export { TestsDashboardCards } from '../admin/TestsDashboardCards.js';
@@ -4,6 +4,7 @@ export function stateGlobal(options) {
4
4
  slug: 'xp-state',
5
5
  dbName: 'xp_state',
6
6
  graphQL: false,
7
+ versions: false,
7
8
  admin: {
8
9
  hidden: true
9
10
  },
package/dist/plugin.js CHANGED
@@ -15,6 +15,8 @@ export function createExperimentsPlugin(input) {
15
15
  // Erase the user subtype only at this non-generic factory boundary.
16
16
  const { collections, globals } = buildCollections(options);
17
17
  const onInit = createOnInit(options);
18
+ const beforeDashboard = config.admin?.components?.beforeDashboard ?? [];
19
+ const beforeNavLinks = config.admin?.components?.beforeNavLinks ?? [];
18
20
  return {
19
21
  ...config,
20
22
  admin: {
@@ -35,8 +37,12 @@ export function createExperimentsPlugin(input) {
35
37
  path: '/experiments/:segments*'
36
38
  }
37
39
  },
38
- beforeNavLinks: [
39
- ...config.admin?.components?.beforeNavLinks ?? [],
40
+ beforeDashboard: beforeDashboard.includes(paths.XP_TESTS_DASHBOARD_CARDS) ? beforeDashboard : [
41
+ ...beforeDashboard,
42
+ paths.XP_TESTS_DASHBOARD_CARDS
43
+ ],
44
+ beforeNavLinks: beforeNavLinks.includes(paths.XP_TESTS_NAV_LINK) ? beforeNavLinks : [
45
+ ...beforeNavLinks,
40
46
  paths.XP_TESTS_NAV_LINK
41
47
  ]
42
48
  }
@@ -1,3 +1,9 @@
1
+ export declare const slotLabels: {
2
+ readonly control: "Original";
3
+ readonly variantA: "Variant A";
4
+ readonly variantB: "Variant B";
5
+ readonly variantC: "Variant C";
6
+ };
1
7
  export declare const pickerCopy: {
2
8
  readonly label: "Experiment";
3
9
  readonly none: "No experiment";
package/dist/puck/copy.js CHANGED
@@ -1,3 +1,10 @@
1
+ // Kept separate from admin copy because the Puck entries cannot import admin modules.
2
+ export const slotLabels = {
3
+ control: 'Original',
4
+ variantA: 'Variant A',
5
+ variantB: 'Variant B',
6
+ variantC: 'Variant C'
7
+ };
1
8
  export const pickerCopy = {
2
9
  label: 'Experiment',
3
10
  none: 'No experiment',
@@ -156,6 +156,8 @@ function EditorRender(props) {
156
156
  experiment ? /*#__PURE__*/ _jsx("a", {
157
157
  className: "xp-slot-button xp-slot-button-primary",
158
158
  href: `/admin/experiments/${encodeURIComponent(String(experimentId))}${experiment.status === 'draft' ? '' : '/results'}`,
159
+ target: "_blank",
160
+ rel: "noopener noreferrer",
159
161
  children: experiment.status === 'draft' ? editorCopy.setup : editorCopy.viewResults
160
162
  }) : null
161
163
  ]
@@ -1,19 +1,24 @@
1
1
  import { createExperimentSlotField } from './experimentSlotField.js';
2
- import { pickerCopy } from './copy.js';
2
+ import { pickerCopy, slotLabels } from './copy.js';
3
3
  export const experimentSlotLabel = pickerCopy.label;
4
4
  export const experimentSlotFields = {
5
5
  experiment: createExperimentSlotField(),
6
- control: {
7
- type: 'slot'
8
- },
9
- variantA: {
10
- type: 'slot'
6
+ // Puck 0.24's defaultSlots prepends each slot, reversing declaration order in the outline.
7
+ variantC: {
8
+ type: 'slot',
9
+ label: slotLabels.variantC
11
10
  },
12
11
  variantB: {
13
- type: 'slot'
12
+ type: 'slot',
13
+ label: slotLabels.variantB
14
14
  },
15
- variantC: {
16
- type: 'slot'
15
+ variantA: {
16
+ type: 'slot',
17
+ label: slotLabels.variantA
18
+ },
19
+ control: {
20
+ type: 'slot',
21
+ label: slotLabels.control
17
22
  }
18
23
  };
19
24
  export const experimentSlotDefaultProps = {
@@ -33,7 +33,7 @@ export interface AnalysisPhase {
33
33
  analysis: {
34
34
  method: 'sequential' | 'bayesian';
35
35
  alpha?: number;
36
- sequentialTuningParameter: number;
36
+ sequentialTuningParameter?: number | null;
37
37
  cuped: 'auto' | 'off';
38
38
  cupedLookbackDays?: number;
39
39
  minExposuresPerArm: number;
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_GAUSSIAN_PRIOR, bonferroniAlpha, capValues, createThetaAdjustedStatistics, effectBayesianABTest, sequentialOneSidedTreatmentGreaterTTest, sequentialOneSidedTreatmentLesserTTest, sequentialTwoSidedTTest, statisticMean, statisticVariance } from '@delmaredigital/experiments-stats';
2
+ import { DEFAULT_SEQUENTIAL_TUNING_PARAMETER } from '../constants.js';
2
3
  import { timestampMs } from './aggregate.js';
3
4
  import { outcomeStatistic } from './cuped.js';
4
5
  import { guardrailStatus } from './guardrails.js';
@@ -17,6 +18,9 @@ const severity = {
17
18
  */ export function analysePhase(input) {
18
19
  const { phase, units } = input;
19
20
  const { analysis } = phase;
21
+ // Start freezes this optional collection field as-is. Match the stats engine's
22
+ // default and record the effective value in every snapshot, including n = 0.
23
+ const sequentialTuningParameter = analysis.sequentialTuningParameter ?? DEFAULT_SEQUENTIAL_TUNING_PARAMETER;
20
24
  const alpha = analysis.alpha ?? 0.05;
21
25
  const prior = analysis.prior ?? DEFAULT_GAUSSIAN_PRIOR;
22
26
  const bayesian = analysis.bayesian ?? {
@@ -73,7 +77,7 @@ const severity = {
73
77
  capped: {}
74
78
  };
75
79
  const params = {
76
- sequentialTuningParameter: analysis.sequentialTuningParameter,
80
+ sequentialTuningParameter,
77
81
  prior: {
78
82
  ...prior
79
83
  },
@@ -158,7 +162,7 @@ const severity = {
158
162
  metric.cupedApplied ||= a.type === 'regressionAdjusted' && b.type === 'regressionAdjusted';
159
163
  const config = {
160
164
  alpha,
161
- sequentialTuningParameter: analysis.sequentialTuningParameter
165
+ sequentialTuningParameter
162
166
  };
163
167
  const inverse = definition.direction === 'decrease';
164
168
  let comparison;
@@ -342,7 +346,7 @@ const severity = {
342
346
  alpha: alphaAdjusted,
343
347
  method: analysis.method,
344
348
  prior,
345
- sequentialTuningParameter: analysis.sequentialTuningParameter
349
+ sequentialTuningParameter
346
350
  });
347
351
  verdict = computeVerdict({
348
352
  ...verdictInput,
@@ -13,6 +13,7 @@ export declare function runResults(payload: Payload, { budgetMs, experimentId, s
13
13
  }): Promise<{
14
14
  ran: number;
15
15
  skipped: number;
16
+ failed: number;
16
17
  swept: number;
17
18
  processedIds: DocId[];
18
19
  budgetExhausted: boolean;
@@ -219,6 +219,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
219
219
  const summary = {
220
220
  ran: 0,
221
221
  skipped: 0,
222
+ failed: 0,
222
223
  swept: 0,
223
224
  processedIds: [],
224
225
  budgetExhausted: false,
@@ -243,6 +244,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
243
244
  });
244
245
  } catch (error) {
245
246
  summary.skipped++;
247
+ if (!(error instanceof ResultsBudgetError)) summary.failed++;
246
248
  summary.budgetExhausted = error instanceof ResultsBudgetError;
247
249
  payload.logger.error({
248
250
  err: error,
@@ -277,6 +279,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
277
279
  if (summary.processedIds.length < 100) summary.processedIds.push(id);
278
280
  } catch (error) {
279
281
  summary.skipped++;
282
+ if (!(error instanceof ResultsBudgetError)) summary.failed++;
280
283
  summary.budgetExhausted ||= error instanceof ResultsBudgetError;
281
284
  payload.logger.error({
282
285
  err: error,
@@ -296,6 +299,7 @@ export async function runResults(payload, { budgetMs = 20_000, experimentId, swe
296
299
  logger: payload.logger
297
300
  });
298
301
  } catch (error) {
302
+ if (!(error instanceof ResultsBudgetError)) summary.failed++;
299
303
  summary.budgetExhausted ||= error instanceof ResultsBudgetError;
300
304
  payload.logger.error({
301
305
  err: error
@@ -4,7 +4,7 @@ import type { ReadinessCode } from '../transitions/checkStart.js';
4
4
  export type { ReadinessCode } from '../transitions/checkStart.js';
5
5
  import type { Snapshot, VerdictCode } from './snapshot.js';
6
6
  export type ArmIssueCode = 'weights-total' | 'control-count' | 'control-first' | 'holdout-last' | 'holdout-count' | 'variant-count' | 'duplicate-key' | 'duplicate-slot' | 'slot-required' | 'invalid-key';
7
- export type RefusalCode = ArmIssueCode | 'not-found' | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-no-targets' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'duplicate-metric';
7
+ export type RefusalCode = ArmIssueCode | 'not-found' | 'no-planned-duration' | 'no-primary-metric' | 'no-placements' | 'empty-slot' | 'page-no-targets' | 'page-user-unit' | 'flag-no-point' | 'flag-params-invalid' | 'flag-point-busy' | 'flag-placement-missing' | 'layer-full' | 'layer-unit-mismatch' | 'unknown-metric-source' | 'unit-source-mismatch' | 'no-reach-per-day' | 'invalid-status' | 'stale-draft' | 'commit-unverified' | 'allocation-lower' | 'confirmation-required' | 'invalid-winner' | 'slices-taken' | 'locked-placement' | 'not-implemented' | 'forbidden' | 'unauthenticated' | 'body-too-large' | 'invalid-body' | 'too-many-ids' | 'invalid-phase' | 'invalid-ids' | 'recompute-failed' | 'analysis-failed' | 'undeclared-targeting-key' | 'invalid-targeting-rule' | 'duplicate-metric';
8
8
  export type ErrorEnvelope = {
9
9
  errors: Array<{
10
10
  code: RefusalCode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-experiments",
3
- "version": "0.2.0-rc.4",
3
+ "version": "0.2.0-rc.6",
4
4
  "description": "No-code A/B testing for Payload CMS and Next.js: block, page and flag experiments with holdouts, layers, exposure logging and honest statistics inside the admin — no vendor",
5
5
  "type": "module",
6
6
  "license": "MIT",