@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.
- package/README.md +74 -16
- package/dist/admin/PreviewLinks.js +45 -24
- package/dist/admin/TestsNavLink.d.ts +3 -1
- package/dist/admin/TestsNavLink.js +2 -2
- package/dist/admin/armSelect/ArmSelectField.js +14 -22
- package/dist/admin/arms/arms.model.d.ts +1 -21
- package/dist/admin/arms/arms.model.js +1 -153
- package/dist/admin/copy/arms.d.ts +1 -6
- package/dist/admin/copy/arms.js +1 -16
- package/dist/admin/copy/readiness.d.ts +4 -1
- package/dist/admin/copy/readiness.js +3 -1
- package/dist/admin/copy/results.d.ts +2 -0
- package/dist/admin/copy/results.js +3 -1
- package/dist/admin/copy/review.d.ts +1 -1
- package/dist/admin/copy/review.js +1 -1
- package/dist/admin/copy/surface.d.ts +0 -1
- package/dist/admin/copy/surface.js +0 -1
- package/dist/admin/paths.d.ts +2 -1
- package/dist/admin/paths.js +2 -1
- package/dist/admin/previewLinks.model.d.ts +2 -0
- package/dist/admin/previewLinks.model.js +3 -0
- package/dist/admin/results/legacy.d.ts +16 -0
- package/dist/admin/results/legacy.js +27 -0
- package/dist/admin/results/results.model.d.ts +6 -6
- package/dist/admin/results/results.model.js +14 -5
- package/dist/admin/results/snapshot.d.ts +14 -0
- package/dist/admin/results/snapshot.js +3 -0
- package/dist/admin/surface/TestsHome.js +6 -9
- package/dist/admin/surface/goals/data.js +16 -16
- package/dist/admin/surface/home.model.d.ts +1 -0
- package/dist/admin/surface/newTest/NewTest.js +4 -4
- package/dist/admin/surface/readiness/ActionDialogs.js +4 -5
- package/dist/admin/surface/readiness/readiness.model.js +8 -1
- package/dist/admin/surface/results/ResultsBody.js +2 -1
- package/dist/admin/surface/results/recommendation.model.d.ts +3 -2
- package/dist/admin/surface/results/recommendation.model.js +10 -3
- package/dist/admin/surface/surface.css +0 -1
- package/dist/admin/surface/test/GoalSection.js +7 -15
- package/dist/admin/surface/test/testPage.model.d.ts +2 -3
- package/dist/admin/surface/test/testPage.model.js +1 -21
- package/dist/admin/surface/useTestDocument.js +2 -1
- package/dist/admin/useLayerUsage.js +14 -23
- package/dist/admin/views/TestsNavLinkServer.d.ts +2 -0
- package/dist/admin/views/TestsNavLinkServer.js +43 -0
- package/dist/arms/copy.d.ts +6 -0
- package/dist/arms/copy.js +16 -0
- package/dist/arms/model.d.ts +23 -0
- package/dist/arms/model.js +173 -0
- package/dist/db/appends.d.ts +6 -2
- package/dist/db/appends.js +10 -4
- package/dist/db/locks.d.ts +6 -0
- package/dist/db/locks.js +13 -1
- package/dist/db/pageOccupancy.d.ts +12 -0
- package/dist/db/pageOccupancy.js +45 -0
- package/dist/db/snapshots.js +4 -3
- package/dist/exports/adminRsc.d.ts +1 -0
- package/dist/exports/adminRsc.js +1 -0
- package/dist/exports/index.d.ts +1 -1
- package/dist/exports/puck.d.ts +2 -1
- package/dist/exports/puck.js +1 -1
- package/dist/exports/puckEditor.d.ts +3 -1
- package/dist/exports/puckEditor.js +2 -1
- package/dist/exports/rsc.d.ts +1 -0
- package/dist/hooks/pageGuard.js +21 -3
- package/dist/lib/fetchAllDocs.d.ts +16 -0
- package/dist/lib/fetchAllDocs.js +56 -0
- package/dist/options.js +2 -9
- package/dist/plugin.js +4 -0
- package/dist/puck/ExperimentPicker.js +47 -16
- package/dist/puck/ExperimentSlot.d.ts +51 -1
- package/dist/puck/ExperimentSlot.js +23 -12
- package/dist/puck/copy.d.ts +5 -4
- package/dist/puck/copy.js +5 -4
- package/dist/puck/editor/CreateExperimentDrawer.js +46 -56
- package/dist/puck/editor/ExperimentSlotEditor.d.ts +52 -1
- package/dist/puck/editor/ExperimentSlotEditor.js +292 -137
- package/dist/puck/editor/ResultsReadout.d.ts +6 -0
- package/dist/puck/editor/ResultsReadout.js +99 -0
- package/dist/puck/editor/VersionBuilder.d.ts +34 -0
- package/dist/puck/editor/VersionBuilder.js +317 -0
- package/dist/puck/editor/armWrites.d.ts +7 -0
- package/dist/puck/editor/armWrites.js +71 -0
- package/dist/puck/editor/armsCache.d.ts +14 -0
- package/dist/puck/editor/armsCache.js +78 -0
- package/dist/puck/editor/copy.d.ts +33 -1
- package/dist/puck/editor/copy.js +45 -8
- package/dist/puck/editor/createTest.d.ts +1 -1
- package/dist/puck/editor/createTest.js +25 -14
- package/dist/puck/editor/editor.chrome.css +47 -161
- package/dist/puck/editor/editor.css +47 -161
- package/dist/puck/editor/editor.model.d.ts +4 -1
- package/dist/puck/editor/editor.model.js +10 -7
- package/dist/puck/editor/experimentOutlinePlugin.d.ts +3 -0
- package/dist/puck/editor/experimentOutlinePlugin.js +89 -0
- package/dist/puck/experimentSlotField.d.ts +2 -0
- package/dist/puck/experimentSlotField.js +11 -3
- package/dist/puck/hostContext.d.ts +10 -0
- package/dist/puck/hostContext.js +12 -0
- package/dist/puck/routes.d.ts +12 -0
- package/dist/puck/routes.js +11 -0
- package/dist/puck/slotConfig.d.ts +1 -1
- package/dist/puck/slotConfig.js +2 -2
- package/dist/sanitizeReadinessDetail.js +10 -1
- package/dist/server/metadata.js +9 -2
- package/dist/server/normaliseContentRelationships.d.ts +7 -0
- package/dist/server/normaliseContentRelationships.js +62 -0
- package/dist/server/recordContentChange.d.ts +1 -1
- package/dist/server/recordContentChange.js +17 -8
- package/dist/server/runResults.js +36 -4
- package/dist/transitions/checkStart.d.ts +5 -1
- package/dist/transitions/checkStart.js +10 -0
- package/dist/transitions/decide.js +16 -1
- package/dist/transitions/placements.d.ts +2 -6
- package/dist/transitions/placements.js +8 -1
- package/dist/transitions/preflightData.d.ts +8 -0
- package/dist/transitions/preflightData.js +116 -36
- package/dist/transitions/start.js +33 -3
- package/dist/types/contracts.d.ts +19 -13
- package/dist/types/endpoints.d.ts +1 -1
- package/dist/ui/Tabs.d.ts +4 -2
- package/dist/ui/Tabs.js +33 -26
- package/dist/ui/accent.d.ts +22 -3
- package/dist/ui/accent.js +74 -16
- 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)=>`
|
|
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…',
|
package/dist/admin/paths.d.ts
CHANGED
|
@@ -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/
|
|
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";
|
package/dist/admin/paths.js
CHANGED
|
@@ -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/
|
|
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:
|
|
30
|
-
decisionSnapshot?:
|
|
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:
|
|
76
|
+
snapshot: DisplaySnapshot;
|
|
77
77
|
phase: PhaseSummary;
|
|
78
78
|
metricId: DocId;
|
|
79
79
|
}): ArmRow[];
|
|
80
|
-
export declare function guardrailChips(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:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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 {};
|
|
@@ -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
|
|
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
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|
|
@@ -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
|
|
32
|
+
const response = await fetchAllDocs(`${surface.apiRoute.replace(/\/$/, '')}/xp-layers?depth=0`, {
|
|
32
33
|
credentials: 'include',
|
|
33
34
|
signal: controller.signal
|
|
34
35
|
});
|
|
35
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 {
|
|
3
|
+
import type { VerdictCode } from '../../../types/snapshot.js';
|
|
4
|
+
import { type DisplaySnapshot } from '../../results/snapshot.js';
|
|
4
5
|
export interface RecommendationContext {
|
|
5
|
-
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
|
|
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(
|
|
50
|
-
'rollout-readout': (ctx)=>rolloutReadoutRecommendation(
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
29
|
+
setMetrics(response.docs);
|
|
38
30
|
setFailed(false);
|
|
39
31
|
setLoaded(true);
|
|
40
32
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Arm,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
220
|
+
_section: TEST_PAGE.staleEdit
|
|
220
221
|
};
|
|
221
222
|
} else if (!response.ok) session.view.errors = {
|
|
222
223
|
...session.view.errors,
|