@delmaredigital/payload-experiments 0.3.0-rc.1 → 0.3.0-rc.4

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 (124) hide show
  1. package/README.md +74 -16
  2. package/dist/admin/PreviewLinks.js +45 -24
  3. package/dist/admin/TestsNavLink.d.ts +3 -1
  4. package/dist/admin/TestsNavLink.js +2 -2
  5. package/dist/admin/armSelect/ArmSelectField.js +14 -22
  6. package/dist/admin/arms/arms.model.d.ts +1 -21
  7. package/dist/admin/arms/arms.model.js +1 -153
  8. package/dist/admin/copy/arms.d.ts +1 -6
  9. package/dist/admin/copy/arms.js +1 -16
  10. package/dist/admin/copy/readiness.d.ts +4 -1
  11. package/dist/admin/copy/readiness.js +3 -1
  12. package/dist/admin/copy/results.d.ts +2 -0
  13. package/dist/admin/copy/results.js +3 -1
  14. package/dist/admin/copy/review.d.ts +1 -1
  15. package/dist/admin/copy/review.js +1 -1
  16. package/dist/admin/copy/surface.d.ts +0 -1
  17. package/dist/admin/copy/surface.js +0 -1
  18. package/dist/admin/paths.d.ts +2 -1
  19. package/dist/admin/paths.js +2 -1
  20. package/dist/admin/previewLinks.model.d.ts +2 -0
  21. package/dist/admin/previewLinks.model.js +3 -0
  22. package/dist/admin/results/legacy.d.ts +16 -0
  23. package/dist/admin/results/legacy.js +27 -0
  24. package/dist/admin/results/results.model.d.ts +6 -6
  25. package/dist/admin/results/results.model.js +14 -5
  26. package/dist/admin/results/snapshot.d.ts +14 -0
  27. package/dist/admin/results/snapshot.js +3 -0
  28. package/dist/admin/surface/TestsHome.js +6 -9
  29. package/dist/admin/surface/goals/data.js +16 -16
  30. package/dist/admin/surface/home.model.d.ts +1 -0
  31. package/dist/admin/surface/newTest/NewTest.js +4 -4
  32. package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
  33. package/dist/admin/surface/readiness/readiness.model.js +8 -1
  34. package/dist/admin/surface/results/ResultsBody.js +2 -1
  35. package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
  36. package/dist/admin/surface/results/recommendation.model.js +10 -3
  37. package/dist/admin/surface/surface.css +0 -1
  38. package/dist/admin/surface/test/GoalSection.js +7 -15
  39. package/dist/admin/surface/test/testPage.model.d.ts +2 -3
  40. package/dist/admin/surface/test/testPage.model.js +1 -21
  41. package/dist/admin/surface/useTestDocument.js +2 -1
  42. package/dist/admin/useLayerUsage.js +14 -23
  43. package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
  44. package/dist/admin/views/TestsNavLinkServer.js +43 -0
  45. package/dist/arms/copy.d.ts +6 -0
  46. package/dist/arms/copy.js +16 -0
  47. package/dist/arms/model.d.ts +23 -0
  48. package/dist/arms/model.js +173 -0
  49. package/dist/db/appends.d.ts +6 -2
  50. package/dist/db/appends.js +10 -4
  51. package/dist/db/locks.d.ts +6 -0
  52. package/dist/db/locks.js +13 -1
  53. package/dist/db/pageOccupancy.d.ts +12 -0
  54. package/dist/db/pageOccupancy.js +45 -0
  55. package/dist/db/snapshots.js +4 -3
  56. package/dist/exports/adminRsc.d.ts +1 -0
  57. package/dist/exports/adminRsc.js +1 -0
  58. package/dist/exports/index.d.ts +1 -1
  59. package/dist/exports/puck.d.ts +2 -1
  60. package/dist/exports/puck.js +1 -1
  61. package/dist/exports/puckEditor.d.ts +3 -1
  62. package/dist/exports/puckEditor.js +2 -1
  63. package/dist/exports/rsc.d.ts +1 -0
  64. package/dist/hooks/pageGuard.js +21 -3
  65. package/dist/lib/fetchAllDocs.d.ts +16 -0
  66. package/dist/lib/fetchAllDocs.js +56 -0
  67. package/dist/options.js +2 -9
  68. package/dist/plugin.js +4 -0
  69. package/dist/puck/ExperimentPicker.js +47 -16
  70. package/dist/puck/ExperimentSlot.d.ts +51 -1
  71. package/dist/puck/ExperimentSlot.js +23 -12
  72. package/dist/puck/copy.d.ts +5 -4
  73. package/dist/puck/copy.js +5 -4
  74. package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
  75. package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
  76. package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
  77. package/dist/puck/editor/ResultsReadout.d.ts +6 -0
  78. package/dist/puck/editor/ResultsReadout.js +99 -0
  79. package/dist/puck/editor/VersionBuilder.d.ts +34 -0
  80. package/dist/puck/editor/VersionBuilder.js +317 -0
  81. package/dist/puck/editor/armWrites.d.ts +7 -0
  82. package/dist/puck/editor/armWrites.js +71 -0
  83. package/dist/puck/editor/armsCache.d.ts +14 -0
  84. package/dist/puck/editor/armsCache.js +78 -0
  85. package/dist/puck/editor/copy.d.ts +33 -1
  86. package/dist/puck/editor/copy.js +45 -8
  87. package/dist/puck/editor/createTest.d.ts +1 -1
  88. package/dist/puck/editor/createTest.js +25 -14
  89. package/dist/puck/editor/editor.chrome.css +47 -161
  90. package/dist/puck/editor/editor.css +47 -161
  91. package/dist/puck/editor/editor.model.d.ts +4 -1
  92. package/dist/puck/editor/editor.model.js +10 -7
  93. package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
  94. package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
  95. package/dist/puck/experimentSlotField.d.ts +2 -0
  96. package/dist/puck/experimentSlotField.js +11 -3
  97. package/dist/puck/hostContext.d.ts +10 -0
  98. package/dist/puck/hostContext.js +12 -0
  99. package/dist/puck/routes.d.ts +12 -0
  100. package/dist/puck/routes.js +11 -0
  101. package/dist/puck/slotConfig.d.ts +1 -1
  102. package/dist/puck/slotConfig.js +2 -2
  103. package/dist/sanitizeReadinessDetail.js +10 -1
  104. package/dist/server/metadata.js +9 -2
  105. package/dist/server/normaliseContentRelationships.d.ts +7 -0
  106. package/dist/server/normaliseContentRelationships.js +62 -0
  107. package/dist/server/recordContentChange.d.ts +1 -1
  108. package/dist/server/recordContentChange.js +17 -8
  109. package/dist/server/runResults.js +36 -4
  110. package/dist/transitions/checkStart.d.ts +5 -1
  111. package/dist/transitions/checkStart.js +10 -0
  112. package/dist/transitions/decide.js +16 -1
  113. package/dist/transitions/placements.d.ts +2 -6
  114. package/dist/transitions/placements.js +8 -1
  115. package/dist/transitions/preflightData.d.ts +8 -0
  116. package/dist/transitions/preflightData.js +116 -36
  117. package/dist/transitions/start.js +33 -3
  118. package/dist/types/contracts.d.ts +19 -13
  119. package/dist/types/endpoints.d.ts +1 -1
  120. package/dist/ui/Tabs.d.ts +4 -2
  121. package/dist/ui/Tabs.js +33 -26
  122. package/dist/ui/accent.d.ts +22 -3
  123. package/dist/ui/accent.js +74 -16
  124. package/package.json +5 -2
@@ -9,7 +9,7 @@ export const RESULTS_COPY = {
9
9
  sequential: 'Sequential',
10
10
  bayesian: 'Bayesian'
11
11
  },
12
- asOfDecision: (date)=>`as of the decision, ${date}`,
12
+ asOfDecision: (date)=>`numbers from ${date}, used for the decision`,
13
13
  kinds: {
14
14
  binomial: 'yes/no',
15
15
  count: 'amount',
@@ -57,6 +57,8 @@ export const RESULTS_COPY = {
57
57
  infeasible: 'A clear answer would take more than 90 days at this traffic.',
58
58
  noCap: 'Not capped: fewer than 20 people had this outcome in the 90 days before Start, so every value counts in full.',
59
59
  quality: 'Data quality',
60
+ missingWinner: 'The stored winner is missing from this round’s results.',
61
+ missingWinnerNext: 'Recompute; if it persists, contact a developer before trusting these results.',
60
62
  srm: "Traffic isn't splitting as set. Check for caching or a page placed twice.",
61
63
  multiGroup: 'People who saw more than one group were excluded.',
62
64
  multiGroupNext: 'Check for caching or a page placed twice before trusting the results.',
@@ -1,8 +1,8 @@
1
1
  export declare const REVIEW_COPY: {
2
2
  readonly noBaseline: "No data yet for this metric.";
3
3
  readonly preview: "Preview";
4
+ readonly previewBlocked: "Your browser blocked the preview window. Allow pop-ups for this site, then try again.";
4
5
  readonly placedTimes: (n: number) => string;
5
- readonly noPreview: "Preview isn't set up for this page type";
6
6
  readonly raise: "Raise allocation";
7
7
  readonly addMetric: "Add a metric";
8
8
  readonly allocationBody: "Raising allocation adds people; nobody already taking part changes group. Lowering needs a new round.";
@@ -1,8 +1,8 @@
1
1
  export const REVIEW_COPY = {
2
2
  noBaseline: 'No data yet for this metric.',
3
3
  preview: 'Preview',
4
+ previewBlocked: 'Your browser blocked the preview window. Allow pop-ups for this site, then try again.',
4
5
  placedTimes: (n)=>`(placed ${n} times on this page)`,
5
- noPreview: "Preview isn't set up for this page type",
6
6
  raise: 'Raise allocation',
7
7
  addMetric: 'Add a metric',
8
8
  allocationBody: 'Raising allocation adds people; nobody already taking part changes group. Lowering needs a new round.',
@@ -10,7 +10,6 @@ export declare const SURFACE_COPY: {
10
10
  readonly dashShowTests: "Show all tests";
11
11
  readonly dashShowGoals: "Show all goals";
12
12
  readonly backToAdmin: "Back to admin";
13
- readonly limitedList: "Showing the 200 most recent tests.";
14
13
  readonly notInstalled: "The Tests screen isn't installed yet. Run `payload generate:importmap` and reload.";
15
14
  readonly noAccess: "You don't have access to tests.";
16
15
  readonly loading: "Loading tests…";
@@ -10,7 +10,6 @@ export const SURFACE_COPY = {
10
10
  dashShowTests: 'Show all tests',
11
11
  dashShowGoals: 'Show all goals',
12
12
  backToAdmin: 'Back to admin',
13
- limitedList: 'Showing the 200 most recent tests.',
14
13
  notInstalled: "The Tests screen isn't installed yet. Run `payload generate:importmap` and reload.",
15
14
  noAccess: "You don't have access to tests.",
16
15
  loading: 'Loading tests…',
@@ -4,6 +4,7 @@ export declare const XP_ARM_SELECT = "@delmaredigital/payload-experiments/admin/
4
4
  export declare const XP_TESTS_VIEW = "@delmaredigital/payload-experiments/admin/rsc#ExperimentsRootView";
5
5
  export declare const XP_TESTS_LIST_REDIRECT = "@delmaredigital/payload-experiments/admin/rsc#CollectionListRedirect";
6
6
  export declare const XP_TEST_EDIT_REDIRECT = "@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect";
7
- export declare const XP_TESTS_NAV_LINK = "@delmaredigital/payload-experiments/admin/client#TestsNavLink";
7
+ export declare const XP_TESTS_NAV_LINK = "@delmaredigital/payload-experiments/admin/rsc#TestsNavLinkServer";
8
+ export declare const XP_TESTS_NAV_CLIENT = "@delmaredigital/payload-experiments/admin/client#TestsNavLink";
8
9
  export declare const XP_TESTS_SURFACE = "@delmaredigital/payload-experiments/admin/client#TestsSurface";
9
10
  export declare const XP_TESTS_DASHBOARD_CARDS = "@delmaredigital/payload-experiments/admin/client#TestsDashboardCards";
@@ -4,6 +4,7 @@ export const XP_ARM_SELECT = '@delmaredigital/payload-experiments/admin/client#A
4
4
  export const XP_TESTS_VIEW = '@delmaredigital/payload-experiments/admin/rsc#ExperimentsRootView';
5
5
  export const XP_TESTS_LIST_REDIRECT = '@delmaredigital/payload-experiments/admin/rsc#CollectionListRedirect';
6
6
  export const XP_TEST_EDIT_REDIRECT = '@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect';
7
- export const XP_TESTS_NAV_LINK = '@delmaredigital/payload-experiments/admin/client#TestsNavLink';
7
+ export const XP_TESTS_NAV_LINK = '@delmaredigital/payload-experiments/admin/rsc#TestsNavLinkServer';
8
+ export const XP_TESTS_NAV_CLIENT = '@delmaredigital/payload-experiments/admin/client#TestsNavLink';
8
9
  export const XP_TESTS_SURFACE = '@delmaredigital/payload-experiments/admin/client#TestsSurface';
9
10
  export const XP_TESTS_DASHBOARD_CARDS = '@delmaredigital/payload-experiments/admin/client#TestsDashboardCards';
@@ -6,4 +6,6 @@ type PreviewPage = Placement & {
6
6
  };
7
7
  /** Preview URLs identify pages; counts use the most placements in any one version. */
8
8
  export declare function previewPages(placements: readonly Placement[], flagPoint?: string): PreviewPage[];
9
+ /** Pages that offer something to do: a preview or an editor link. The section is hidden when none do. */
10
+ export declare function actionablePreviewPages(placements: readonly Placement[], flagPoint?: string): PreviewPage[];
9
11
  export {};
@@ -30,3 +30,6 @@
30
30
  ...pages.values()
31
31
  ].map(({ page })=>page);
32
32
  }
33
+ /** Pages that offer something to do: a preview or an editor link. The section is hidden when none do. */ export function actionablePreviewPages(placements, flagPoint) {
34
+ return previewPages(placements, flagPoint).filter((page)=>page.url || page.editorUrl);
35
+ }
@@ -0,0 +1,16 @@
1
+ import type { PhaseSummary } from '../../types/endpoints.js';
2
+ import type { ResultsContext } from './results.model.js';
3
+ import type { LegacySnapshot } from './snapshot.js';
4
+ /** Stands in for CORE-15a winnerArm, borrowing the live decision only for the newest rollout. */
5
+ export declare function legacyRolloutLine(snapshot: LegacySnapshot, phase: PhaseSummary, phases: PhaseSummary[], decision?: {
6
+ winnerArm?: string;
7
+ }): string;
8
+ /** Stands in for CORE-15a minExposuresPerArm; absent historical minima keep the old dash. */
9
+ export declare function legacyMinimum(snapshot: LegacySnapshot, ctx: ResultsContext): number | undefined;
10
+ /** Stands in for CORE-15a minExposuresPerArm by deriving the old notice from the frozen verdict. */
11
+ export declare function legacyTooFew(snapshot: LegacySnapshot): boolean;
12
+ /** Stands in for CORE-15a winnerArm in rollout recommendations using the old recommended arm and label. */
13
+ export declare function legacyRecommendationWinner(snapshot: LegacySnapshot, phase?: PhaseSummary): {
14
+ key: string | null;
15
+ label: string;
16
+ };
@@ -0,0 +1,27 @@
1
+ import { RESULTS_COPY, shippingTitle } from '../copy/results.js';
2
+ import { armLabel } from '../labels.js';
3
+ /** Stands in for CORE-15a winnerArm, borrowing the live decision only for the newest rollout. */ export function legacyRolloutLine(snapshot, phase, phases, decision) {
4
+ const newestRollout = phases.filter((phase)=>phase.phaseKind === 'rollout').reduce((latest, phase)=>latest === null || phase.phase > latest ? phase.phase : latest, null);
5
+ const winner = snapshot.winnerArm ?? (snapshot.phase === newestRollout ? decision?.winnerArm : undefined);
6
+ return shippingTitle(winner ? phase.arms.find((arm)=>arm.key === winner)?.label ?? armLabel(winner) : RESULTS_COPY.shippedGroup);
7
+ }
8
+ /** Stands in for CORE-15a minExposuresPerArm; absent historical minima keep the old dash. */ export function legacyMinimum(snapshot, ctx) {
9
+ return snapshot.minExposuresPerArm ?? ctx.minExposuresPerArm;
10
+ }
11
+ /** Stands in for CORE-15a minExposuresPerArm by deriving the old notice from the frozen verdict. */ export function legacyTooFew(snapshot) {
12
+ return snapshot.verdict.code === 'collecting' || snapshot.verdict.code === 'rollout-collecting';
13
+ }
14
+ /** Stands in for CORE-15a winnerArm in rollout recommendations using the old recommended arm and label. */ export function legacyRecommendationWinner(snapshot, phase) {
15
+ const key = snapshot.winnerArm ?? snapshot.verdict.recommendedArm;
16
+ const stored = phase?.arms.find((arm)=>arm.key === key)?.label.trim();
17
+ return {
18
+ key,
19
+ label: stored || (key && [
20
+ 'control',
21
+ 'variantA',
22
+ 'variantB',
23
+ 'variantC',
24
+ 'holdout'
25
+ ].includes(key) ? armLabel(key) : RESULTS_COPY.shippedGroup)
26
+ };
27
+ }
@@ -1,13 +1,13 @@
1
1
  import type { DocId, Status } from '../../types/contracts.js';
2
2
  import type { PhaseSummary, ResultsResponse } from '../../types/endpoints.js';
3
3
  import type { Snapshot } from '../../types/snapshot.js';
4
+ import { type DisplaySnapshot } from './snapshot.js';
4
5
  type Metric = Snapshot['metrics'][number];
5
6
  type Arm = Metric['perArm'][number];
6
7
  type Decision = {
7
8
  phase: number;
8
9
  winnerArm?: string;
9
10
  };
10
- type DisplaySnapshot = Snapshot;
11
11
  export declare function defaultRound({ phases, decision, status }: Pick<ResultsResponse, 'phases'> & {
12
12
  decision?: Decision | null;
13
13
  status: Status;
@@ -26,8 +26,8 @@ export type VerdictCardModel = {
26
26
  guardrailLine?: string;
27
27
  };
28
28
  export declare function verdictCard({ snapshot, decisionSnapshot, isDecidedTestRound, winnerLabel }: {
29
- snapshot: Snapshot;
30
- decisionSnapshot?: Snapshot;
29
+ snapshot: DisplaySnapshot;
30
+ decisionSnapshot?: DisplaySnapshot;
31
31
  isDecidedTestRound: boolean;
32
32
  winnerLabel: string;
33
33
  }): VerdictCardModel;
@@ -73,11 +73,11 @@ export interface ArmRow {
73
73
  comparison?: ComparisonModel;
74
74
  }
75
75
  export declare function armRows({ snapshot, phase, metricId }: {
76
- snapshot: Snapshot;
76
+ snapshot: DisplaySnapshot;
77
77
  phase: PhaseSummary;
78
78
  metricId: DocId;
79
79
  }): ArmRow[];
80
- export declare function guardrailChips(snapshot: Snapshot): {
80
+ export declare function guardrailChips(snapshot: DisplaySnapshot): {
81
81
  name: string;
82
82
  status: "OK" | "Watch" | "Failing" | "Too little data";
83
83
  }[];
@@ -105,5 +105,5 @@ export interface DataQualityItem {
105
105
  shares?: string[];
106
106
  }
107
107
  export declare function dataQuality(snapshot: DisplaySnapshot, ctx?: ResultsContext): DataQualityItem[];
108
- export declare function verdictLabel(snapshot: Snapshot): string;
108
+ export declare function verdictLabel(snapshot: DisplaySnapshot): string;
109
109
  export {};
@@ -2,6 +2,8 @@ import { READINESS_COPY, versionPageUnavailableCopy, versionPageUncheckedCopy }
2
2
  import { armLabel, phaseKindLabel, verdictCodeLabel } from '../labels.js';
3
3
  import { RESULTS_COPY, cappedCount, collectingCopy, conflictCopy, decidedTitle, exclusionCopy, ignoredCopy, metricUnavailable, moreDays, roundLabel, shareCopy, shippingTitle, srmP, uncappedTotal } from '../copy/results.js';
4
4
  import { formatCI, formatCount, formatMetric, formatPercent, formatProbability } from './format.js';
5
+ import { legacyMinimum, legacyRolloutLine, legacyTooFew } from './legacy.js';
6
+ import { isLegacySnapshot } from './snapshot.js';
5
7
  export function defaultRound({ phases, decision, status }) {
6
8
  if ((status === 'decided' || status === 'archived') && decision) return decision.phase;
7
9
  return phases.reduce((latest, phase)=>latest === null || phase.phase > latest ? phase.phase : latest, null);
@@ -29,9 +31,10 @@ export function verdictCard({ snapshot, decisionSnapshot, isDecidedTestRound, wi
29
31
  }
30
32
  export function rolloutLine(snapshot, phase, phases, decision) {
31
33
  if (snapshot.phaseKind !== 'rollout') return null;
32
- const newestRollout = phases.filter((phase)=>phase.phaseKind === 'rollout').reduce((latest, phase)=>latest === null || phase.phase > latest ? phase.phase : latest, null);
33
- const winner = snapshot.winnerArm ?? (snapshot.phase === newestRollout ? decision?.winnerArm : undefined);
34
- return shippingTitle(winner ? phase.arms.find((arm)=>arm.key === winner)?.label ?? armLabel(winner) : RESULTS_COPY.shippedGroup);
34
+ return isLegacySnapshot(snapshot) ? legacyRolloutLine(snapshot, phase, phases, decision) : currentRolloutLine(snapshot, phase);
35
+ }
36
+ function currentRolloutLine(snapshot, phase) {
37
+ return snapshot.winnerArm === null ? null : shippingTitle(phase.arms.find((arm)=>arm.key === snapshot.winnerArm)?.label ?? armLabel(snapshot.winnerArm));
35
38
  }
36
39
  export function cappedCell({ metric, arm, caps }) {
37
40
  const cap = caps?.[metric.metricId];
@@ -125,7 +128,7 @@ function smallest(values) {
125
128
  if (phaseKind !== 'rollout' || snapshot.verdict.code !== 'rollout-collecting') return null;
126
129
  const keys = comparedKeys(snapshot, ctx.phase);
127
130
  const primary = snapshot.metrics.find((metric)=>metric.role === 'primary');
128
- return collectingCopy(formatCount(smallest(keys.map((key)=>snapshot.exposures.perArm[key]))), formatCount(snapshot.minExposuresPerArm ?? ctx.minExposuresPerArm), formatCount(smallest(keys.map((key)=>primary?.perArm.find((arm)=>arm.arm === key)?.converters))), formatCount(primary ? snapshot.params.minSampleSizes[primary.metricId] : undefined));
131
+ return collectingCopy(formatCount(smallest(keys.map((key)=>snapshot.exposures.perArm[key]))), formatCount(isLegacySnapshot(snapshot) ? legacyMinimum(snapshot, ctx) : snapshot.minExposuresPerArm), formatCount(smallest(keys.map((key)=>primary?.perArm.find((arm)=>arm.arm === key)?.converters))), formatCount(primary ? snapshot.params.minSampleSizes[primary.metricId] : undefined));
129
132
  }
130
133
  export function dataQuality(snapshot, ctx = {}) {
131
134
  const items = (snapshot.health ?? []).map((item)=>({
@@ -133,6 +136,12 @@ export function dataQuality(snapshot, ctx = {}) {
133
136
  message: READINESS_COPY.needsAttention,
134
137
  nextStep: item.code === 'version-page-unavailable' ? versionPageUnavailableCopy(item.path, ctx.status) : versionPageUncheckedCopy(item.path, ctx.status)
135
138
  }));
139
+ // Test snapshots store null by design; only rollout snapshots record the shipped winner.
140
+ if (!isLegacySnapshot(snapshot) && snapshot.phaseKind === 'rollout' && snapshot.winnerArm === null) items.push({
141
+ id: 'missing-winner',
142
+ message: RESULTS_COPY.missingWinner,
143
+ nextStep: RESULTS_COPY.missingWinnerNext
144
+ });
136
145
  const srmTotal = Object.keys(snapshot.srm.expected).reduce((total, key)=>total + (snapshot.exposures.perArm[key] ?? 0), 0);
137
146
  if (snapshot.srm.flagged) items.push({
138
147
  id: 'srm',
@@ -159,7 +168,7 @@ export function dataQuality(snapshot, ctx = {}) {
159
168
  detail: conflictCopy(formatPercent(exposures.identityConflictShare)),
160
169
  nextStep: RESULTS_COPY.identityNext
161
170
  });
162
- if (snapshot.verdict.code === 'collecting' || snapshot.verdict.code === 'rollout-collecting') items.push({
171
+ if (isLegacySnapshot(snapshot) ? legacyTooFew(snapshot) : snapshot.verdict.code === 'collecting' || snapshot.verdict.code === 'rollout-collecting') items.push({
163
172
  id: 'too-few',
164
173
  message: RESULTS_COPY.tooFew,
165
174
  nextStep: RESULTS_COPY.tooFewNext
@@ -0,0 +1,14 @@
1
+ import type { Snapshot } from '../../types/snapshot.js';
2
+ type Core15aFields = Pick<Snapshot, 'winnerArm' | 'minExposuresPerArm'>;
3
+ /** Immutable history written before CORE-15a, with at least one added field absent. */
4
+ export type LegacySnapshot = Omit<Snapshot, keyof Core15aFields> & ({
5
+ winnerArm?: undefined;
6
+ minExposuresPerArm?: number;
7
+ } | {
8
+ winnerArm?: string | null;
9
+ minExposuresPerArm?: undefined;
10
+ });
11
+ export type DisplaySnapshot = Snapshot | LegacySnapshot;
12
+ /** A stored null winner is current data; only missing CORE-15a fields mark history. */
13
+ export declare function isLegacySnapshot(snapshot: DisplaySnapshot): snapshot is LegacySnapshot;
14
+ export {};
@@ -0,0 +1,3 @@
1
+ /** A stored null winner is current data; only missing CORE-15a fields mark history. */ export function isLegacySnapshot(snapshot) {
2
+ return snapshot.winnerArm === undefined || snapshot.minExposuresPerArm === undefined;
3
+ }
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useEffect, useMemo, useState } from 'react';
4
4
  import { Link } from '@payloadcms/ui';
5
5
  import { formatAdminURL } from 'payload/shared';
6
+ import { fetchAllDocs } from '../../lib/fetchAllDocs.js';
6
7
  import { createApi } from '../api.js';
7
8
  import { noMatchesLine, SURFACE_COPY } from '../copy/surface.js';
8
9
  import { Banner, EmptyState, StatusChip } from '../../ui/Feedback.js';
@@ -19,7 +20,8 @@ const HOME_FIELDS = [
19
20
  'decidedAt',
20
21
  'decision',
21
22
  'unitType',
22
- 'flagPoint'
23
+ 'flagPoint',
24
+ 'updatedAt'
23
25
  ];
24
26
  // Typecheck fails if HomeTest gains a field the list request does not select (Payload always returns id).
25
27
  const homeFieldsComplete = true;
@@ -42,19 +44,18 @@ export function TestsHome({ adminRoute, apiRoute, canManage = false, crumbs, fla
42
44
  });
43
45
  async function load() {
44
46
  try {
45
- const response = await fetch(`${apiRoute.replace(/\/$/, '')}/xp-experiments?depth=0&sort=-updatedAt&limit=200&${HOME_SELECT}`, {
47
+ const response = await fetchAllDocs(`${apiRoute.replace(/\/$/, '')}/xp-experiments?depth=0&sort=id&${HOME_SELECT}`, {
46
48
  credentials: 'include',
47
49
  signal: controller.signal
48
50
  });
49
51
  if (!response.ok) throw new Error('list');
50
- const { docs, totalDocs } = await response.json();
52
+ const docs = response.docs.sort((a, b)=>(Date.parse(b.updatedAt ?? '') || 0) - (Date.parse(a.updatedAt ?? '') || 0));
51
53
  if (!active) return;
52
54
  const summary = await api.summary(docs.map((doc)=>doc.id));
53
55
  if (active) setState({
54
56
  loading: false,
55
57
  error: false,
56
58
  tests: docs,
57
- limited: totalDocs > docs.length,
58
59
  summaries: summary.ok ? summary.data ?? {} : {},
59
60
  summaryError: !summary.ok
60
61
  });
@@ -156,11 +157,7 @@ export function TestsHome({ adminRoute, apiRoute, canManage = false, crumbs, fla
156
157
  flagPoints: flagPoints,
157
158
  search: search,
158
159
  clear: ()=>setSearch('')
159
- }),
160
- state.limited ? /*#__PURE__*/ _jsx("p", {
161
- className: "xp-tests-limited",
162
- children: SURFACE_COPY.limitedList
163
- }) : null
160
+ })
164
161
  ]
165
162
  })
166
163
  });
@@ -1,3 +1,4 @@
1
+ import { DocsResponseError, fetchAllDocs } from '../../../lib/fetchAllDocs.js';
1
2
  import { refusalMessage } from '../../copy/common.js';
2
3
  import { GOALS_COPY as C } from '../../copy/goals.js';
3
4
  import { payloadFieldErrors } from '../testDocument.model.js';
@@ -9,24 +10,23 @@ export class GoalResponseError extends Error {
9
10
  }
10
11
  /** Map REST refusals while retaining Payload's field validation details. */ export async function goalResponse(response, fallback, options) {
11
12
  const body = await response.json().catch(()=>undefined);
12
- if (!response.ok) {
13
- const fields = payloadFieldErrors(body, '_section', fallback);
14
- const first = body?.errors?.[0];
15
- throw new GoalResponseError(refusalMessage(first?.code, response.status, Object.values(fields).join(' ') || fallback, options), fields);
16
- }
13
+ if (!response.ok) goalRefusal(body, response.status, fallback, options);
17
14
  if (body == null) throw new Error(fallback);
18
15
  return body;
19
16
  }
17
+ function goalRefusal(body, status, fallback, options) {
18
+ const fields = payloadFieldErrors(body, '_section', fallback);
19
+ const first = body?.errors?.[0];
20
+ throw new GoalResponseError(refusalMessage(first?.code, status, Object.values(fields).join(' ') || fallback, options), fields);
21
+ }
20
22
  export async function goalPages(base, collection, signal) {
21
- const all = [];
22
- for(let page = 1;; page++){
23
- const response = await fetch(`${base}/${collection}?depth=0&limit=100&page=${page}`, {
24
- credentials: 'include',
25
- signal
26
- });
27
- const body = await goalResponse(response, C.loadError);
28
- if (!Array.isArray(body.docs)) throw new Error(C.loadError);
29
- all.push(...body.docs);
30
- if (!body.hasNextPage) return all;
31
- }
23
+ const result = await fetchAllDocs(`${base}/${collection}?depth=0`, {
24
+ credentials: 'include',
25
+ signal
26
+ }).catch((cause)=>{
27
+ if (cause instanceof DocsResponseError) throw new Error(C.loadError);
28
+ throw cause;
29
+ });
30
+ if (!result.ok) goalRefusal(result.body, result.status, C.loadError);
31
+ return result.docs;
32
32
  }
@@ -7,6 +7,7 @@ export interface HomeTest {
7
7
  kind: ExperimentKind;
8
8
  status: Status;
9
9
  unitType: UnitType;
10
+ updatedAt?: string | null;
10
11
  flagPoint?: string | null;
11
12
  analysis?: {
12
13
  plannedDurationDays?: number | null;
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { fetchAllDocs } from '../../../lib/fetchAllDocs.js';
3
4
  import { useEffect, useRef, useState } from 'react';
4
5
  import { useRouter } from 'next/navigation.js';
5
6
  import { formatAdminURL } from 'payload/shared';
@@ -28,14 +29,13 @@ export function NewTest({ surface, pages }) {
28
29
  setLayerState('loading');
29
30
  void (async ()=>{
30
31
  try {
31
- const response = await fetch(`${surface.apiRoute.replace(/\/$/, '')}/xp-layers?depth=0&limit=100`, {
32
+ const response = await fetchAllDocs(`${surface.apiRoute.replace(/\/$/, '')}/xp-layers?depth=0`, {
32
33
  credentials: 'include',
33
34
  signal: controller.signal
34
35
  });
35
- const body = await response.json();
36
- if (!response.ok || !Array.isArray(body?.docs)) throw new Error('groups');
36
+ if (!response.ok) throw new Error('groups');
37
37
  if (!controller.signal.aborted) {
38
- setLayers(body.docs);
38
+ setLayers(response.docs);
39
39
  setLayerState('ready');
40
40
  }
41
41
  } catch {
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { DocsResponseError, fetchAllDocs } from '../../../lib/fetchAllDocs.js';
3
4
  import { useEffect, useState } from 'react';
4
5
  import { Button } from '../../../ui/Button.js';
5
6
  import { TextInput } from '../../../ui/Inputs.js';
@@ -192,16 +193,14 @@ import { useTransition } from './useTransition.js';
192
193
  const controller = new AbortController();
193
194
  void (async ()=>{
194
195
  try {
195
- const response = await fetch(`${surface.apiRoute.replace(/\/$/, '')}/xp-metrics?depth=0&limit=100`, {
196
+ const response = await fetchAllDocs(`${surface.apiRoute.replace(/\/$/, '')}/xp-metrics?depth=0`, {
196
197
  credentials: 'include',
197
198
  signal: controller.signal
198
199
  });
199
200
  if (!response.ok) throw new Error(refusalMessage(undefined, response.status));
200
- const data = await response.json();
201
- if (!Array.isArray(data.docs)) throw new Error(API_MESSAGES.unreadable);
202
- if (!controller.signal.aborted) setMetrics(data.docs);
201
+ if (!controller.signal.aborted) setMetrics(response.docs);
203
202
  } catch (cause) {
204
- if (!controller.signal.aborted) setLoadError(cause instanceof Error ? cause.message : API_MESSAGES.network);
203
+ if (!controller.signal.aborted) setLoadError(cause instanceof DocsResponseError ? API_MESSAGES.unreadable : cause instanceof Error ? cause.message : API_MESSAGES.network);
205
204
  } finally{
206
205
  if (!controller.signal.aborted) setLoading(false);
207
206
  }
@@ -1,6 +1,6 @@
1
1
  import { formatDate } from '../../format/date.js';
2
2
  import { actionGroupLabel } from '../../actions/actions.model.js';
3
- import { unsupportedContentCopy, versionPageUnavailableCopy, versionPageUncheckedCopy, placementFailureCopy, unpublishedPageCopy, READINESS_COPY as C, attributeNext, contentNext, goalPeopleNext, parameterNext, placementNext, ruleNext, versionsNext } from '../../copy/readiness.js';
3
+ import { unsupportedContentCopy, versionPageUnavailableCopy, versionPageUncheckedCopy, placementFailureCopy, pageInUseCopy, unpublishedPageCopy, READINESS_COPY as C, attributeNext, contentNext, goalPeopleNext, parameterNext, placementNext, ruleNext, versionsNext } from '../../copy/readiness.js';
4
4
  import { SECTION_ORDER } from '../contracts.js';
5
5
  const object = (value)=>value && typeof value === 'object' && !Array.isArray(value) ? value : {};
6
6
  const first = (value)=>object(Array.isArray(value) ? value[0] : value);
@@ -49,6 +49,13 @@ const versions = row('versions', (detail)=>versionsNext(detailText(detail, C.ver
49
49
  'no-reach-per-day': row('length', ()=>C.noReach),
50
50
  'layer-full': row('versions', ()=>C.full),
51
51
  'layer-unit-mismatch': row('header', ()=>C.differentPeople),
52
+ 'page-in-use': {
53
+ ...row('versions', (detail)=>{
54
+ const paths = object(detail).paths;
55
+ return pageInUseCopy(text(Array.isArray(paths) ? paths[0] : undefined, 'this page'));
56
+ }),
57
+ nextStep: C.chooseDifferentPage
58
+ },
52
59
  'page-no-targets': row('versions', ()=>C.noTargets),
53
60
  'page-target-unpublished': row('versions', (detail)=>unpublishedPageCopy(text(detail, 'This page'))),
54
61
  'page-targets-unchecked': row('versions', ()=>C.uncheckedPages),
@@ -12,6 +12,7 @@ import { METRICS_COPY } from '../../copy/metrics.js';
12
12
  import { API_MESSAGES } from '../../copy/common.js';
13
13
  import { armLabel } from '../../labels.js';
14
14
  import { PreviewLinks } from '../../PreviewLinks.js';
15
+ import { actionablePreviewPages } from '../../previewLinks.model.js';
15
16
  import { VerdictHero } from '../../results/VerdictHero.js';
16
17
  import { StatTile } from '../../results/StatTile.js';
17
18
  import { DataTable, Swatch } from '../../results/DataTable.js';
@@ -329,7 +330,7 @@ function ResultsContent({ id, surface, onRecomputed, onPagesReleased, renderPage
329
330
  }) : null
330
331
  ]
331
332
  }),
332
- manage && placements && placements.length > 0 ? /*#__PURE__*/ _jsxs("section", {
333
+ manage && placements && actionablePreviewPages(placements, doc?.flagPoint).length > 0 ? /*#__PURE__*/ _jsxs("section", {
333
334
  className: "xp-results-block",
334
335
  children: [
335
336
  /*#__PURE__*/ _jsx("div", {
@@ -1,8 +1,9 @@
1
1
  import type { PhaseSummary } from '../../../types/endpoints.js';
2
2
  import type { Status } from '../../../types/contracts.js';
3
- import type { Snapshot, VerdictCode } from '../../../types/snapshot.js';
3
+ import type { VerdictCode } from '../../../types/snapshot.js';
4
+ import { type DisplaySnapshot } from '../../results/snapshot.js';
4
5
  export interface RecommendationContext {
5
- snapshot: Snapshot;
6
+ snapshot: DisplaySnapshot;
6
7
  phase?: PhaseSummary;
7
8
  plannedDurationDays?: number | null;
8
9
  status?: Status;
@@ -1,6 +1,9 @@
1
1
  import { formatDate } from '../../format/date.js';
2
2
  import { armLabel } from '../../labels.js';
3
3
  import { formatCount, formatPercent } from '../../results/format.js';
4
+ import { RESULTS_COPY } from '../../copy/results.js';
5
+ import { legacyRecommendationWinner } from '../../results/legacy.js';
6
+ import { isLegacySnapshot } from '../../results/snapshot.js';
4
7
  import { RECOMMENDATION_COPY as C, collectingRecommendation, guardrailRecommendation, mixedRecommendation, rolloutCollectingRecommendation, rolloutReadoutRecommendation, runningRecommendation, winnerRecommendation, worseRecommendation, storedGuardrailFinding } from '../../copy/recommendation.js';
5
8
  function label(ctx, key, fallback = C.group) {
6
9
  const stored = ctx.phase?.arms.find((arm)=>arm.key === key)?.label.trim();
@@ -14,7 +17,10 @@ function label(ctx, key, fallback = C.group) {
14
17
  }
15
18
  const primary = ({ snapshot })=>snapshot.metrics.find((metric)=>metric.role === 'primary');
16
19
  const worse = (ctx)=>primary(ctx)?.comparisons.find((comparison)=>comparison.significant === 'worse' && ctx.phase?.arms.some((arm)=>arm.key === comparison.arm && arm.kind === 'variant'))?.arm;
17
- const winner = (ctx)=>label(ctx, ctx.snapshot.winnerArm ?? ctx.snapshot.verdict.recommendedArm, C.winner);
20
+ const rolloutWinner = (ctx)=>isLegacySnapshot(ctx.snapshot) ? legacyRecommendationWinner(ctx.snapshot, ctx.phase) : {
21
+ key: ctx.snapshot.winnerArm,
22
+ label: label(ctx, ctx.snapshot.winnerArm)
23
+ };
18
24
  /** Select stored findings and format them; never run inference in the admin. */ export const RECOMMENDATION = {
19
25
  unavailable: ()=>C.unavailable,
20
26
  'data-problem': ()=>C.dataProblem,
@@ -46,10 +52,11 @@ const winner = (ctx)=>label(ctx, ctx.snapshot.winnerArm ?? ctx.snapshot.verdict.
46
52
  return Number.isFinite(end) ? runningRecommendation(formatDate(end)) : C.keepRunning;
47
53
  },
48
54
  'no-difference': ()=>C.noDifference,
49
- 'rollout-collecting': (ctx)=>rolloutCollectingRecommendation(winner(ctx)),
50
- 'rollout-readout': (ctx)=>rolloutReadoutRecommendation(winner(ctx), formatPercent(primary(ctx)?.comparisons.find((comparison)=>comparison.arm === (ctx.snapshot.winnerArm ?? ctx.snapshot.verdict.recommendedArm))?.lift, true))
55
+ 'rollout-collecting': (ctx)=>rolloutCollectingRecommendation(rolloutWinner(ctx).label),
56
+ 'rollout-readout': (ctx)=>rolloutReadoutRecommendation(rolloutWinner(ctx).label, formatPercent(primary(ctx)?.comparisons.find((comparison)=>comparison.arm === rolloutWinner(ctx).key)?.lift, true))
51
57
  };
52
58
  export const recommendation = (ctx)=>{
59
+ if (!isLegacySnapshot(ctx.snapshot) && ctx.snapshot.phaseKind === 'rollout' && ctx.snapshot.winnerArm === null) return RESULTS_COPY.missingWinner;
53
60
  const sentence = RECOMMENDATION[ctx.snapshot.verdict.code];
54
61
  return sentence(ctx);
55
62
  };
@@ -43,7 +43,6 @@
43
43
  .xp-tests-group-chevron { align-self: center; color: var(--xp-text-3); }
44
44
  .xp-tests-group[open] .xp-tests-group-chevron { transform: rotate(90deg); }
45
45
  .xp-tests-group > summary + .xp-tests-list { margin-top: 10px; }
46
- .xp-tests-limited { margin-top: var(--xp-space-4); color: var(--xp-text-2); font: var(--xp-text-small); }
47
46
  .xp-tests-no-matches { display: flex; flex-wrap: wrap; align-items: center; gap: var(--xp-space-2); }
48
47
  .xp-tests-no-matches p { margin: 0; }
49
48
  @media (max-width: 720px) {
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { fetchAllDocs } from '../../../lib/fetchAllDocs.js';
3
4
  import { Button } from '../../../ui/Button.js';
4
5
  import { useEffect, useState } from 'react';
5
6
  import { formatAdminURL } from 'payload/shared';
@@ -19,22 +20,13 @@ export function GoalSection({ test, surface }) {
19
20
  try {
20
21
  await Promise.resolve();
21
22
  if (controller.signal.aborted) return;
22
- const all = [];
23
- let page = 1;
24
- for(;;){
25
- const response = await fetch(`${surface.apiRoute.replace(/\/$/, '')}/xp-metrics?depth=0&limit=100&page=${page}`, {
26
- credentials: 'include',
27
- signal: controller.signal
28
- });
29
- if (!response.ok) throw new Error();
30
- const body = await response.json();
31
- if (!Array.isArray(body.docs)) throw new Error();
32
- all.push(...body.docs);
33
- if (!body.hasNextPage) break;
34
- page++;
35
- }
23
+ const response = await fetchAllDocs(`${surface.apiRoute.replace(/\/$/, '')}/xp-metrics?depth=0`, {
24
+ credentials: 'include',
25
+ signal: controller.signal
26
+ });
27
+ if (!response.ok) throw new Error();
36
28
  if (!controller.signal.aborted) {
37
- setMetrics(all);
29
+ setMetrics(response.docs);
38
30
  setFailed(false);
39
31
  setLoaded(true);
40
32
  }
@@ -1,4 +1,4 @@
1
- import type { Arm, ArmKey, ExperimentKind, ExposurePolicy } from '../../../types/contracts.js';
1
+ import type { Arm, ExperimentKind, ExposurePolicy } from '../../../types/contracts.js';
2
2
  import type { PreflightResult } from '../../../types/endpoints.js';
3
3
  export declare function orderedVersions(arms: readonly Arm[]): Array<{
4
4
  arm: Arm;
@@ -13,5 +13,4 @@ export declare function durationSuggestion(preflight: PreflightResult | null, sa
13
13
  feasible: boolean;
14
14
  } | null;
15
15
  export declare function exposureOptions(kind: ExperimentKind): readonly ExposurePolicy[];
16
- /** Keep the edited share exact and distribute the remaining whole percentages proportionally. */
17
- export declare function balanceWeight(arms: Arm[], key: ArmKey, weight: number): Arm[];
16
+ export { balanceWeight } from '../../../arms/model.js';
@@ -1,4 +1,3 @@
1
- import { setWeight } from '../../arms/arms.model.js';
2
1
  const ORDER = [
3
2
  'control',
4
3
  'variantA',
@@ -31,23 +30,4 @@ export function exposureOptions(kind) {
31
30
  'manual'
32
31
  ];
33
32
  }
34
- /** Keep the edited share exact and distribute the remaining whole percentages proportionally. */ export function balanceWeight(arms, key, weight) {
35
- if (!arms.some((arm)=>arm.key === key) || !Number.isInteger(weight) || weight < 0 || weight > 100) return arms;
36
- const others = arms.filter((arm)=>arm.key !== key);
37
- if (!others.length) return arms;
38
- const total = others.reduce((sum, arm)=>sum + Math.max(0, Number.isFinite(arm.weight) ? arm.weight : 0), 0);
39
- const denominator = total || others.length;
40
- const raw = others.map((arm)=>(total ? Math.max(0, Number.isFinite(arm.weight) ? arm.weight : 0) : 1) * (100 - weight) / denominator);
41
- const whole = raw.map(Math.floor);
42
- const order = raw.map((_, i)=>i).sort((a, b)=>raw[b] - whole[b] - (raw[a] - whole[a]) || a - b);
43
- const remainder = 100 - weight - whole.reduce((sum, value)=>sum + value, 0);
44
- for(let i = 0; i < remainder; i++)whole[order[i]]++;
45
- const shares = new Map(others.map((arm, i)=>[
46
- arm.key,
47
- whole[i]
48
- ]));
49
- return setWeight(arms, key, weight).map((arm)=>shares.has(arm.key) ? {
50
- ...arm,
51
- weight: shares.get(arm.key)
52
- } : arm);
53
- }
33
+ export { balanceWeight } from '../../../arms/model.js';
@@ -215,8 +215,9 @@ function stage(session, partial) {
215
215
  ...edit
216
216
  }), {});
217
217
  session.queue = [];
218
+ // Server text is never shown; the stale-edit sentence is the admin's own.
218
219
  session.view.errors = {
219
- _section: staleError.message || TEST_PAGE.staleEdit
220
+ _section: TEST_PAGE.staleEdit
220
221
  };
221
222
  } else if (!response.ok) session.view.errors = {
222
223
  ...session.view.errors,