@qoretechnologies/reqraft 0.10.56 → 0.10.57-pr.127.g0edd18d
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/.claude/CLAUDE.md +1 -0
- package/README.md +20 -0
- package/design/TRACKING.md +278 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/tracking/browser/attach.d.ts +8 -0
- package/dist/tracking/browser/attach.d.ts.map +1 -0
- package/dist/tracking/browser/attach.js +182 -0
- package/dist/tracking/browser/attach.js.map +1 -0
- package/dist/tracking/browser/env.d.ts +14 -0
- package/dist/tracking/browser/env.d.ts.map +1 -0
- package/dist/tracking/browser/env.js +150 -0
- package/dist/tracking/browser/env.js.map +1 -0
- package/dist/tracking/core/consent.d.ts +21 -0
- package/dist/tracking/core/consent.d.ts.map +1 -0
- package/dist/tracking/core/consent.js +39 -0
- package/dist/tracking/core/consent.js.map +1 -0
- package/dist/tracking/core/engagement.d.ts +20 -0
- package/dist/tracking/core/engagement.d.ts.map +1 -0
- package/dist/tracking/core/engagement.js +52 -0
- package/dist/tracking/core/engagement.js.map +1 -0
- package/dist/tracking/core/experiments.d.ts +109 -0
- package/dist/tracking/core/experiments.d.ts.map +1 -0
- package/dist/tracking/core/experiments.js +294 -0
- package/dist/tracking/core/experiments.js.map +1 -0
- package/dist/tracking/core/hash.d.ts +16 -0
- package/dist/tracking/core/hash.d.ts.map +1 -0
- package/dist/tracking/core/hash.js +31 -0
- package/dist/tracking/core/hash.js.map +1 -0
- package/dist/tracking/core/ids.d.ts +6 -0
- package/dist/tracking/core/ids.d.ts.map +1 -0
- package/dist/tracking/core/ids.js +31 -0
- package/dist/tracking/core/ids.js.map +1 -0
- package/dist/tracking/core/observable.d.ts +12 -0
- package/dist/tracking/core/observable.d.ts.map +1 -0
- package/dist/tracking/core/observable.js +32 -0
- package/dist/tracking/core/observable.js.map +1 -0
- package/dist/tracking/core/queue.d.ts +47 -0
- package/dist/tracking/core/queue.d.ts.map +1 -0
- package/dist/tracking/core/queue.js +205 -0
- package/dist/tracking/core/queue.js.map +1 -0
- package/dist/tracking/core/sections.d.ts +54 -0
- package/dist/tracking/core/sections.d.ts.map +1 -0
- package/dist/tracking/core/sections.js +156 -0
- package/dist/tracking/core/sections.js.map +1 -0
- package/dist/tracking/core/storage.d.ts +15 -0
- package/dist/tracking/core/storage.d.ts.map +1 -0
- package/dist/tracking/core/storage.js +72 -0
- package/dist/tracking/core/storage.js.map +1 -0
- package/dist/tracking/core/tracker.d.ts +122 -0
- package/dist/tracking/core/tracker.d.ts.map +1 -0
- package/dist/tracking/core/tracker.js +390 -0
- package/dist/tracking/core/tracker.js.map +1 -0
- package/dist/tracking/core/types.d.ts +130 -0
- package/dist/tracking/core/types.d.ts.map +1 -0
- package/dist/tracking/core/types.js +69 -0
- package/dist/tracking/core/types.js.map +1 -0
- package/dist/tracking/core/utm.d.ts +6 -0
- package/dist/tracking/core/utm.d.ts.map +1 -0
- package/dist/tracking/core/utm.js +30 -0
- package/dist/tracking/core/utm.js.map +1 -0
- package/dist/tracking/index.d.ts +46 -0
- package/dist/tracking/index.d.ts.map +1 -0
- package/dist/tracking/index.js +91 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/tracking/markup.d.ts +12 -0
- package/dist/tracking/markup.d.ts.map +1 -0
- package/dist/tracking/markup.js +19 -0
- package/dist/tracking/markup.js.map +1 -0
- package/dist/tracking/react/Experiment.d.ts +23 -0
- package/dist/tracking/react/Experiment.d.ts.map +1 -0
- package/dist/tracking/react/Experiment.js +28 -0
- package/dist/tracking/react/Experiment.js.map +1 -0
- package/dist/tracking/react/TrackingProvider.d.ts +26 -0
- package/dist/tracking/react/TrackingProvider.d.ts.map +1 -0
- package/dist/tracking/react/TrackingProvider.js +45 -0
- package/dist/tracking/react/TrackingProvider.js.map +1 -0
- package/dist/tracking/react/hooks.d.ts +39 -0
- package/dist/tracking/react/hooks.d.ts.map +1 -0
- package/dist/tracking/react/hooks.js +96 -0
- package/dist/tracking/react/hooks.js.map +1 -0
- package/dist/tracking/react/index.d.ts +7 -0
- package/dist/tracking/react/index.d.ts.map +1 -0
- package/dist/tracking/react/index.js +17 -0
- package/dist/tracking/react/index.js.map +1 -0
- package/dist/tracking/testing.d.ts +40 -0
- package/dist/tracking/testing.d.ts.map +1 -0
- package/dist/tracking/testing.js +140 -0
- package/dist/tracking/testing.js.map +1 -0
- package/dist/tracking/ui/ExperimentCard.d.ts +47 -0
- package/dist/tracking/ui/ExperimentCard.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentCard.js +305 -0
- package/dist/tracking/ui/ExperimentCard.js.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts +53 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.js +359 -0
- package/dist/tracking/ui/ExperimentsOverlay.js.map +1 -0
- package/dist/tracking/ui/anchor.d.ts +107 -0
- package/dist/tracking/ui/anchor.d.ts.map +1 -0
- package/dist/tracking/ui/anchor.js +108 -0
- package/dist/tracking/ui/anchor.js.map +1 -0
- package/dist/tracking/ui/card.d.ts +87 -0
- package/dist/tracking/ui/card.d.ts.map +1 -0
- package/dist/tracking/ui/card.js +287 -0
- package/dist/tracking/ui/card.js.map +1 -0
- package/dist/tracking/ui/client.d.ts +42 -0
- package/dist/tracking/ui/client.d.ts.map +1 -0
- package/dist/tracking/ui/client.js +123 -0
- package/dist/tracking/ui/client.js.map +1 -0
- package/dist/tracking/ui/index.d.ts +20 -0
- package/dist/tracking/ui/index.d.ts.map +1 -0
- package/dist/tracking/ui/index.js +54 -0
- package/dist/tracking/ui/index.js.map +1 -0
- package/dist/tracking/ui/types.d.ts +111 -0
- package/dist/tracking/ui/types.d.ts.map +1 -0
- package/dist/tracking/ui/types.js +8 -0
- package/dist/tracking/ui/types.js.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts +31 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.js +136 -0
- package/dist/tracking/ui/useExperimentsAdmin.js.map +1 -0
- package/package.json +1 -1
- package/src/index.tsx +5 -0
- package/src/stories/tracking.tsx +197 -0
- package/src/tracking/browser/attach.ts +186 -0
- package/src/tracking/browser/env.ts +78 -0
- package/src/tracking/core/consent.ts +37 -0
- package/src/tracking/core/engagement.ts +47 -0
- package/src/tracking/core/experiments.ts +258 -0
- package/src/tracking/core/hash.ts +26 -0
- package/src/tracking/core/ids.ts +25 -0
- package/src/tracking/core/observable.ts +23 -0
- package/src/tracking/core/queue.ts +131 -0
- package/src/tracking/core/sections.ts +166 -0
- package/src/tracking/core/storage.ts +69 -0
- package/src/tracking/core/tracker.ts +417 -0
- package/src/tracking/core/types.ts +162 -0
- package/src/tracking/core/utm.ts +23 -0
- package/src/tracking/index.ts +72 -0
- package/src/tracking/markup.ts +17 -0
- package/src/tracking/react/Experiment.stories.tsx +174 -0
- package/src/tracking/react/Experiment.tsx +44 -0
- package/src/tracking/react/TrackingProvider.tsx +56 -0
- package/src/tracking/react/hooks.ts +106 -0
- package/src/tracking/react/index.ts +13 -0
- package/src/tracking/testing.ts +108 -0
- package/src/tracking/ui/ExperimentCard.stories.tsx +236 -0
- package/src/tracking/ui/ExperimentCard.tsx +491 -0
- package/src/tracking/ui/ExperimentsOverlay.stories.tsx +148 -0
- package/src/tracking/ui/ExperimentsOverlay.tsx +423 -0
- package/src/tracking/ui/anchor.ts +180 -0
- package/src/tracking/ui/card.ts +323 -0
- package/src/tracking/ui/client.ts +117 -0
- package/src/tracking/ui/index.ts +71 -0
- package/src/tracking/ui/types.ts +104 -0
- package/src/tracking/ui/useExperimentsAdmin.ts +103 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the A/B test card shows, as plain functions of the qorus-api objects: the version rows,
|
|
3
|
+
* the verdict, the numbers line, the bars, the status tag, which actions a status allows, and
|
|
4
|
+
* the words of the confirm step and of the outcome. No React, no DOM (unit-tested in
|
|
5
|
+
* `__tests__/tracking/card.test.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
IAdminVariant,
|
|
9
|
+
IExperimentEntry,
|
|
10
|
+
IExperimentResults,
|
|
11
|
+
TExperimentActionResult,
|
|
12
|
+
TExperimentStatus,
|
|
13
|
+
TLifecycleAction,
|
|
14
|
+
TVersionAction,
|
|
15
|
+
} from './types';
|
|
16
|
+
|
|
17
|
+
export const canAccept = (status: TExperimentStatus) =>
|
|
18
|
+
status === 'running' || status === 'paused' || status === 'stopped';
|
|
19
|
+
/** Start a draft, or resume a paused test. */
|
|
20
|
+
export const canStart = (status: TExperimentStatus) => status === 'draft' || status === 'paused';
|
|
21
|
+
export const canPause = (status: TExperimentStatus) => status === 'running';
|
|
22
|
+
export const canStop = (status: TExperimentStatus) => status === 'running' || status === 'paused';
|
|
23
|
+
export const canRemove = (status: TExperimentStatus, versions: number) =>
|
|
24
|
+
(status === 'draft' || status === 'running' || status === 'paused') && versions > 1;
|
|
25
|
+
|
|
26
|
+
export interface IVersionRow {
|
|
27
|
+
key: string;
|
|
28
|
+
name: string;
|
|
29
|
+
control: boolean;
|
|
30
|
+
/** Visitors exposed in the current phase (null: no numbers yet). */
|
|
31
|
+
visitors: number | null;
|
|
32
|
+
/** Conversion on the primary metric, 0..1. */
|
|
33
|
+
conversion: number | null;
|
|
34
|
+
/** Chance to beat the original on the primary metric, 0..1 (null for the original). */
|
|
35
|
+
chance: number | null;
|
|
36
|
+
/** The page's code renders it (false: the server has a version the code does not, so it cannot be shown). */
|
|
37
|
+
inCode: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const primaryMetric = (results: IExperimentResults | null | undefined) =>
|
|
41
|
+
results?.metrics?.find((m) => m.role === 'primary') ?? null;
|
|
42
|
+
|
|
43
|
+
export const versionName = (key: string) => `Version ${key.toUpperCase()}`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One row per version. Keys come from the server's experiment when there is one, otherwise from
|
|
47
|
+
* the page's code (`codeVariants`, the `data-variants` of `<Experiment>`): a test that exists
|
|
48
|
+
* only in code can still be switched.
|
|
49
|
+
*/
|
|
50
|
+
export const versionRows = (
|
|
51
|
+
entry: IExperimentEntry | undefined,
|
|
52
|
+
codeVariants: string[] = []
|
|
53
|
+
): IVersionRow[] => {
|
|
54
|
+
const metric = primaryMetric(entry?.results);
|
|
55
|
+
const variants: IAdminVariant[] =
|
|
56
|
+
entry?.experiment.variants?.length ?
|
|
57
|
+
entry.experiment.variants
|
|
58
|
+
: codeVariants.map((key, i) => ({ key, weight: 1, control: i === 0 }));
|
|
59
|
+
return variants.map((v) => {
|
|
60
|
+
const r = metric?.variants.find((x) => x.variant === v.key);
|
|
61
|
+
return {
|
|
62
|
+
key: v.key,
|
|
63
|
+
name: v.name || versionName(v.key),
|
|
64
|
+
control: !!v.control,
|
|
65
|
+
visitors: r ? r.exposed : null,
|
|
66
|
+
conversion: r?.rate ?? null,
|
|
67
|
+
chance: v.control ? null : (r?.chance_to_beat ?? null),
|
|
68
|
+
inCode: codeVariants.length === 0 || codeVariants.includes(v.key),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const nameOf = (entry: IExperimentEntry, key: string | null | undefined) =>
|
|
74
|
+
entry.experiment.variants.find((v) => v.key === key)?.name ||
|
|
75
|
+
(key ? versionName(key) : 'a version');
|
|
76
|
+
|
|
77
|
+
/** The verdict in plain words: the server's sentence when it sends one. */
|
|
78
|
+
export const verdictLine = (entry: IExperimentEntry | undefined): string => {
|
|
79
|
+
if (!entry)
|
|
80
|
+
return 'This test is not set up on the server yet: you can switch versions here, but there are no numbers.';
|
|
81
|
+
const { experiment: e, results } = entry;
|
|
82
|
+
if (results?.verdict_text) return results.verdict_text;
|
|
83
|
+
if (e.status === 'concluded')
|
|
84
|
+
return `Finished: ${nameOf(entry, e.winner)} won, and every visitor now sees it.`;
|
|
85
|
+
if (e.status === 'draft')
|
|
86
|
+
return 'Not started yet: press Start test to split visitors between the versions.';
|
|
87
|
+
if (!results) return 'No numbers yet.';
|
|
88
|
+
const control = e.variants.find((v) => v.control)?.key;
|
|
89
|
+
switch (results.verdict) {
|
|
90
|
+
case 'likely-winner':
|
|
91
|
+
return `${nameOf(entry, results.winner_variant)} is likely better than ${nameOf(entry, control)}.`;
|
|
92
|
+
case 'likely-loser':
|
|
93
|
+
return `The new versions are likely worse than ${nameOf(entry, control)}.`;
|
|
94
|
+
case 'no-clear-difference':
|
|
95
|
+
return 'No clear difference between the versions yet.';
|
|
96
|
+
default:
|
|
97
|
+
return 'Too early to tell.';
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const percent = (n: number | null | undefined, digits = 1) =>
|
|
102
|
+
n == null || !Number.isFinite(n) ? '–' : `${(n * 100).toFixed(digits).replace(/\.0$/, '')}%`;
|
|
103
|
+
|
|
104
|
+
export const count = (n: number | null | undefined) =>
|
|
105
|
+
n == null ? '–' : n.toLocaleString('en-US');
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One line of numbers under a version: visitors, conversion, chance to beat the original. In
|
|
109
|
+
* words when there is nothing to count yet, never a row of dashes.
|
|
110
|
+
*/
|
|
111
|
+
export const numbersLine = (row: IVersionRow, metricName?: string, status?: TExperimentStatus) => {
|
|
112
|
+
if (!row.visitors) {
|
|
113
|
+
if (status === 'draft') return 'Not started: no visitors yet';
|
|
114
|
+
return row.visitors === 0 ? 'No visitors yet' : 'No numbers yet';
|
|
115
|
+
}
|
|
116
|
+
const parts = [
|
|
117
|
+
`${count(row.visitors)} visitors`,
|
|
118
|
+
`${percent(row.conversion)} ${metricName ? `“${metricName}”` : 'conversion'}`,
|
|
119
|
+
];
|
|
120
|
+
if (!row.control && row.chance != null)
|
|
121
|
+
parts.push(`${percent(row.chance, 0)} chance to beat the original`);
|
|
122
|
+
return parts.join(' · ');
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Each version's bar: its conversion as a share of the best one (0..100; 0 with no numbers). */
|
|
126
|
+
export const barValues = (rows: IVersionRow[]): number[] => {
|
|
127
|
+
const best = Math.max(0, ...rows.map((r) => r.conversion ?? 0));
|
|
128
|
+
return rows.map((r) => (best > 0 && r.conversion ? Math.round((r.conversion / best) * 100) : 0));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The verdict's colour: green for a likely winner, amber for likely losers, neutral otherwise. */
|
|
132
|
+
export const verdictIntent = (
|
|
133
|
+
entry: IExperimentEntry | undefined
|
|
134
|
+
): 'success' | 'warning' | 'info' | 'muted' => {
|
|
135
|
+
if (!entry) return 'muted';
|
|
136
|
+
if (entry.experiment.status === 'concluded') return 'success';
|
|
137
|
+
const v = entry.results?.verdict;
|
|
138
|
+
return (
|
|
139
|
+
v === 'likely-winner' ? 'success'
|
|
140
|
+
: v === 'likely-loser' ? 'warning'
|
|
141
|
+
: 'info'
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const STATUS_LABEL: Record<TExperimentStatus, string> = {
|
|
146
|
+
draft: 'Draft',
|
|
147
|
+
running: 'Running',
|
|
148
|
+
paused: 'Paused',
|
|
149
|
+
stopped: 'Stopped',
|
|
150
|
+
concluded: 'Ended',
|
|
151
|
+
archived: 'Archived',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type TStatusIntent = 'success' | 'warning' | 'danger' | 'muted';
|
|
155
|
+
|
|
156
|
+
/** The status tag: its words (with the phase after a removal), its icon and its colour. */
|
|
157
|
+
export const statusTag = (
|
|
158
|
+
entry: IExperimentEntry | undefined
|
|
159
|
+
): { label: string; icon: string; intent: TStatusIntent } => {
|
|
160
|
+
if (!entry) return { label: 'Not on the server', icon: 'QuestionLine', intent: 'muted' };
|
|
161
|
+
const { status, phase } = entry.experiment;
|
|
162
|
+
const label = `${STATUS_LABEL[status] ?? status}${phase && phase > 1 ? ` · phase ${phase}` : ''}`;
|
|
163
|
+
switch (status) {
|
|
164
|
+
case 'running':
|
|
165
|
+
return { label, icon: 'PlayCircleLine', intent: 'success' };
|
|
166
|
+
case 'paused':
|
|
167
|
+
return { label, icon: 'PauseCircleLine', intent: 'warning' };
|
|
168
|
+
case 'draft':
|
|
169
|
+
return { label, icon: 'PencilRulerLine', intent: 'muted' };
|
|
170
|
+
case 'stopped':
|
|
171
|
+
return { label, icon: 'StopCircleLine', intent: 'danger' };
|
|
172
|
+
default:
|
|
173
|
+
return { label, icon: 'TrophyLine', intent: 'success' };
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export interface IConfirmCopy {
|
|
178
|
+
title: string;
|
|
179
|
+
description: string;
|
|
180
|
+
confirmLabel: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** What the confirm step says before Accept / Remove. */
|
|
184
|
+
export const confirmCopy = (
|
|
185
|
+
action: TVersionAction,
|
|
186
|
+
entry: IExperimentEntry,
|
|
187
|
+
variant: string
|
|
188
|
+
): IConfirmCopy => {
|
|
189
|
+
const name = nameOf(entry, variant);
|
|
190
|
+
if (action === 'accept') {
|
|
191
|
+
return {
|
|
192
|
+
title: `Make “${name}” the winner?`,
|
|
193
|
+
description: `This ends the test. From then on every visitor sees “${name}”. A developer removes the other versions' code later.`,
|
|
194
|
+
confirmLabel: `Accept ${name}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const rest = entry.experiment.variants.filter((v) => v.key !== variant);
|
|
198
|
+
// The server concludes the test with the last version, a draft included.
|
|
199
|
+
if (rest.length === 1) {
|
|
200
|
+
const last = nameOf(entry, rest[0].key);
|
|
201
|
+
return {
|
|
202
|
+
title: `Remove “${name}”?`,
|
|
203
|
+
description: `Only “${last}” would be left, so it becomes the winner and the test ends. Every visitor then sees “${last}”.`,
|
|
204
|
+
confirmLabel: `Remove and accept ${last}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
title: `Remove “${name}” from the test?`,
|
|
209
|
+
description:
|
|
210
|
+
entry.experiment.status === 'draft' ?
|
|
211
|
+
`The test will start without “${name}”.`
|
|
212
|
+
: `Nobody sees “${name}” any more. The test starts counting again with the remaining versions, so the numbers start from zero.`,
|
|
213
|
+
confirmLabel: `Remove ${name}`,
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** What the confirm step says before Start / Pause / Stop. */
|
|
218
|
+
export const lifecycleCopy = (action: TLifecycleAction, entry: IExperimentEntry): IConfirmCopy => {
|
|
219
|
+
const e = entry.experiment;
|
|
220
|
+
const name = e.name || e.key;
|
|
221
|
+
const versions = e.variants.map((v) => `“${v.name || v.key}”`).join(' and ');
|
|
222
|
+
switch (action) {
|
|
223
|
+
case 'start':
|
|
224
|
+
return e.status === 'paused' ?
|
|
225
|
+
{
|
|
226
|
+
title: `Resume “${name}”?`,
|
|
227
|
+
description: `Visitors are split between ${versions} again and counted.`,
|
|
228
|
+
confirmLabel: 'Resume the test',
|
|
229
|
+
}
|
|
230
|
+
: {
|
|
231
|
+
title: `Start “${name}”?`,
|
|
232
|
+
description: `From now on, visitors who allow analytics are split between ${versions}, and each version's numbers are counted. You can pause or stop it at any time.`,
|
|
233
|
+
confirmLabel: 'Start the test',
|
|
234
|
+
};
|
|
235
|
+
case 'pause':
|
|
236
|
+
return {
|
|
237
|
+
title: `Pause “${name}”?`,
|
|
238
|
+
description: `Everybody sees the original, “${nameOf(entry, e.variants.find((v) => v.control)?.key)}”, and nothing is counted until you resume it.`,
|
|
239
|
+
confirmLabel: 'Pause the test',
|
|
240
|
+
};
|
|
241
|
+
case 'stop':
|
|
242
|
+
default:
|
|
243
|
+
return {
|
|
244
|
+
title: `Stop “${name}”?`,
|
|
245
|
+
description:
|
|
246
|
+
'The test ends without a winner and cannot be started again; everybody sees the original. To end it with a winner, use Accept instead.',
|
|
247
|
+
confirmLabel: 'Stop the test',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** The notification after Accept / Remove. */
|
|
253
|
+
export const actionOutcome = (
|
|
254
|
+
action: TVersionAction,
|
|
255
|
+
result: TExperimentActionResult | null | undefined,
|
|
256
|
+
variant: string
|
|
257
|
+
): string => {
|
|
258
|
+
const winner =
|
|
259
|
+
result?.variants?.find((v) => v.key === result.winner)?.name || result?.winner || variant;
|
|
260
|
+
if (action === 'accept' || result?.auto_accepted)
|
|
261
|
+
return `The test has ended: every visitor now sees “${winner}”.`;
|
|
262
|
+
return 'The version is removed; the test goes on with the others.';
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Why the numbers restarted: the current phase began when a version was removed. Null in the
|
|
267
|
+
* first phase. `actorLabels` names who removed it (`{ 'landing-editor': 'in the editor' }`).
|
|
268
|
+
*/
|
|
269
|
+
export const phaseNote = (
|
|
270
|
+
entry: IExperimentEntry | undefined,
|
|
271
|
+
actorLabels: Record<string, string> = {}
|
|
272
|
+
): string | null => {
|
|
273
|
+
const e = entry?.experiment;
|
|
274
|
+
if (!e || !e.phase || e.phase < 2) return null;
|
|
275
|
+
const current = e.phases?.find((p) => p.phase === e.phase);
|
|
276
|
+
const removed = [...(e.history ?? [])].reverse().find((h) => h.action === 'remove-variant');
|
|
277
|
+
const who = removed?.actor && actorLabels[removed.actor] ? ` ${actorLabels[removed.actor]}` : '';
|
|
278
|
+
const since =
|
|
279
|
+
current?.started_at ?
|
|
280
|
+
new Date(current.started_at).toLocaleDateString('en-GB', {
|
|
281
|
+
day: 'numeric',
|
|
282
|
+
month: 'short',
|
|
283
|
+
timeZone: 'UTC',
|
|
284
|
+
})
|
|
285
|
+
: null;
|
|
286
|
+
const name =
|
|
287
|
+
removed?.variant ?
|
|
288
|
+
e.phases?.flatMap((p) => p.variants).find((v) => v.key === removed.variant)?.name
|
|
289
|
+
: undefined;
|
|
290
|
+
return `Counting since ${since ?? 'the last change'} (phase ${e.phase}): ${name ? `“${name}”` : 'a version'} was removed${who}.`;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/** The note on an element whose test has ended. */
|
|
294
|
+
export const winnerNote = (entry: IExperimentEntry | undefined, shown: string) =>
|
|
295
|
+
`Winner live: ${entry ? nameOf(entry, entry.experiment.winner ?? shown) : versionName(shown)} · code clean-up pending`;
|
|
296
|
+
|
|
297
|
+
// ── how many versions fit ───────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/** Versions visible before the list scrolls: three and a half, so the half row says "there is more". */
|
|
300
|
+
export const VISIBLE_VERSIONS = 3.5;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The versions list's height cap (px): 3.5 rows, or less when the card has less room
|
|
304
|
+
* (`available`, what is left after the header, verdict and footer), never under one row.
|
|
305
|
+
* Null when every row fits (no cap, no scrolling).
|
|
306
|
+
*/
|
|
307
|
+
export const versionsMaxHeight = ({
|
|
308
|
+
rows,
|
|
309
|
+
rowHeight,
|
|
310
|
+
gap,
|
|
311
|
+
available = Infinity,
|
|
312
|
+
}: {
|
|
313
|
+
rows: number;
|
|
314
|
+
rowHeight: number;
|
|
315
|
+
gap: number;
|
|
316
|
+
available?: number;
|
|
317
|
+
}): number | null => {
|
|
318
|
+
if (rows <= 0 || rowHeight <= 0) return null;
|
|
319
|
+
const all = rows * rowHeight + (rows - 1) * gap;
|
|
320
|
+
const threeAndHalf = VISIBLE_VERSIONS * rowHeight + Math.floor(VISIBLE_VERSIONS) * gap;
|
|
321
|
+
const cap = Math.max(rowHeight, Math.min(threeAndHalf, available));
|
|
322
|
+
return all <= cap ? null : Math.round(cap);
|
|
323
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small client for the qorus-api experiment routes the A/B test card acts on: the list with
|
|
3
|
+
* results, accept, remove-variant, start, pause, stop, and creating a draft (§9.3, §9.5 of the
|
|
4
|
+
* contract). Optional: the card and the overlay only take data and callbacks, so a product can
|
|
5
|
+
* reach its server any other way.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
IAdminExperiment,
|
|
9
|
+
IExperimentEntry,
|
|
10
|
+
INewExperiment,
|
|
11
|
+
TExperimentActionResult,
|
|
12
|
+
TLifecycleAction,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
export class ExperimentsAdminError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
readonly status: number,
|
|
19
|
+
readonly code?: string
|
|
20
|
+
) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'ExperimentsAdminError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TExperimentsFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
27
|
+
|
|
28
|
+
export interface IExperimentsAdminOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The list route, with any query it needs: `/qorus-cloud/editor/experiments?property=landing`
|
|
31
|
+
* (the editor route) or `/qorus-saas-admin/analytics/experiments?property=ide` (the admin API).
|
|
32
|
+
*/
|
|
33
|
+
listUrl: string;
|
|
34
|
+
/**
|
|
35
|
+
* The route of one action. Default: the list route's path, then `/<key>/<action>`
|
|
36
|
+
* (`…/experiments/signup-cta/accept`), without the list's query.
|
|
37
|
+
*/
|
|
38
|
+
actionUrl?: (key: string, action: string) => string;
|
|
39
|
+
/** Where a new draft is posted. Default: the list route's path. */
|
|
40
|
+
createUrl?: string;
|
|
41
|
+
/** Sent with every request (e.g. an `Authorization` header). */
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
/** `same-origin` by default. */
|
|
44
|
+
credentials?: RequestCredentials;
|
|
45
|
+
/** Default: the global `fetch`. */
|
|
46
|
+
fetch?: TExperimentsFetch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IExperimentsAdminClient {
|
|
50
|
+
list(): Promise<IExperimentEntry[]>;
|
|
51
|
+
accept(key: string, variant: string): Promise<TExperimentActionResult>;
|
|
52
|
+
remove(key: string, variant: string): Promise<TExperimentActionResult>;
|
|
53
|
+
lifecycle(key: string, action: TLifecycleAction): Promise<IAdminExperiment>;
|
|
54
|
+
create(def: INewExperiment): Promise<IAdminExperiment>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pathOf = (url: string) => url.split('?')[0].replace(/\/+$/, '');
|
|
58
|
+
|
|
59
|
+
export const createExperimentsAdminClient = (
|
|
60
|
+
options: IExperimentsAdminOptions
|
|
61
|
+
): IExperimentsAdminClient => {
|
|
62
|
+
const base = pathOf(options.listUrl);
|
|
63
|
+
const actionUrl =
|
|
64
|
+
options.actionUrl ??
|
|
65
|
+
((key: string, action: string) => `${base}/${encodeURIComponent(key)}/${action}`);
|
|
66
|
+
const doFetch: TExperimentsFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
67
|
+
|
|
68
|
+
const call = async <T>(url: string, body?: unknown): Promise<T> => {
|
|
69
|
+
const res = await doFetch(url, {
|
|
70
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
71
|
+
credentials: options.credentials ?? 'same-origin',
|
|
72
|
+
headers: {
|
|
73
|
+
Accept: 'application/json',
|
|
74
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
75
|
+
...options.headers,
|
|
76
|
+
},
|
|
77
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
78
|
+
});
|
|
79
|
+
const parsed = (await res.json().catch(() => null)) as {
|
|
80
|
+
err?: string;
|
|
81
|
+
error?: string;
|
|
82
|
+
desc?: string;
|
|
83
|
+
message?: string;
|
|
84
|
+
} | null;
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const code = parsed?.err ?? parsed?.error;
|
|
87
|
+
if (res.status === 401 || res.status === 403) {
|
|
88
|
+
throw new ExperimentsAdminError(
|
|
89
|
+
'Not allowed to manage A/B tests on this server (the API key is missing or wrong).',
|
|
90
|
+
res.status,
|
|
91
|
+
code
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
throw new ExperimentsAdminError(
|
|
95
|
+
parsed?.desc ?? parsed?.message ?? code ?? `HTTP ${res.status}`,
|
|
96
|
+
res.status,
|
|
97
|
+
code
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return parsed as T;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
list: () =>
|
|
105
|
+
call<IExperimentEntry[] | { experiments?: IExperimentEntry[] }>(options.listUrl).then(
|
|
106
|
+
(body) =>
|
|
107
|
+
Array.isArray(body) ? body
|
|
108
|
+
: Array.isArray(body?.experiments) ? body.experiments
|
|
109
|
+
: []
|
|
110
|
+
),
|
|
111
|
+
accept: (key, variant) => call<TExperimentActionResult>(actionUrl(key, 'accept'), { variant }),
|
|
112
|
+
remove: (key, variant) =>
|
|
113
|
+
call<TExperimentActionResult>(actionUrl(key, 'remove-variant'), { variant }),
|
|
114
|
+
lifecycle: (key, action) => call<IAdminExperiment>(actionUrl(key, action), {}),
|
|
115
|
+
create: (def) => call<IAdminExperiment>(options.createUrl ?? base, def),
|
|
116
|
+
};
|
|
117
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A/B test UI: `<ExperimentCard>` (one test, driven by props), `<ExperimentsOverlay>` (every
|
|
3
|
+
* `<Experiment>` on the page outlined, with its card anchored to it), and the optional
|
|
4
|
+
* `useExperimentsAdmin` / `createExperimentsAdminClient` for the qorus-api routes. Built on Reqore;
|
|
5
|
+
* the engine (`../index`) does not import this layer. How to embed it: design/TRACKING.md, §8.
|
|
6
|
+
*/
|
|
7
|
+
export { ExperimentCard } from './ExperimentCard';
|
|
8
|
+
export type { IExperimentCardProps } from './ExperimentCard';
|
|
9
|
+
export { boxOf, ExperimentsOverlay, OVERLAY_ATTRIBUTE } from './ExperimentsOverlay';
|
|
10
|
+
export type { IExperimentsOverlayProps } from './ExperimentsOverlay';
|
|
11
|
+
export { useExperimentsAdmin } from './useExperimentsAdmin';
|
|
12
|
+
export type { IUseExperimentsAdmin, IUseExperimentsAdminOptions } from './useExperimentsAdmin';
|
|
13
|
+
export { createExperimentsAdminClient, ExperimentsAdminError } from './client';
|
|
14
|
+
export type {
|
|
15
|
+
IExperimentsAdminClient,
|
|
16
|
+
IExperimentsAdminOptions,
|
|
17
|
+
TExperimentsFetch,
|
|
18
|
+
} from './client';
|
|
19
|
+
export {
|
|
20
|
+
activatesRow,
|
|
21
|
+
CARD_MAX_WIDTH,
|
|
22
|
+
CARD_MIN_HEIGHT,
|
|
23
|
+
cardReducer,
|
|
24
|
+
CLOSED,
|
|
25
|
+
openCard,
|
|
26
|
+
OUTSIDE_IGNORE,
|
|
27
|
+
placeCard,
|
|
28
|
+
} from './anchor';
|
|
29
|
+
export type { IBox, ICardState, IPlaceInput, IPlacement, TCardEvent } from './anchor';
|
|
30
|
+
export {
|
|
31
|
+
actionOutcome,
|
|
32
|
+
barValues,
|
|
33
|
+
canAccept,
|
|
34
|
+
canPause,
|
|
35
|
+
canRemove,
|
|
36
|
+
canStart,
|
|
37
|
+
canStop,
|
|
38
|
+
confirmCopy,
|
|
39
|
+
count,
|
|
40
|
+
lifecycleCopy,
|
|
41
|
+
numbersLine,
|
|
42
|
+
percent,
|
|
43
|
+
phaseNote,
|
|
44
|
+
primaryMetric,
|
|
45
|
+
STATUS_LABEL,
|
|
46
|
+
statusTag,
|
|
47
|
+
verdictIntent,
|
|
48
|
+
verdictLine,
|
|
49
|
+
versionName,
|
|
50
|
+
versionRows,
|
|
51
|
+
versionsMaxHeight,
|
|
52
|
+
VISIBLE_VERSIONS,
|
|
53
|
+
winnerNote,
|
|
54
|
+
} from './card';
|
|
55
|
+
export type { IConfirmCopy, IVersionRow, TStatusIntent } from './card';
|
|
56
|
+
export type {
|
|
57
|
+
IAdminExperiment,
|
|
58
|
+
IAdminExperimentEvent,
|
|
59
|
+
IAdminExperimentPhase,
|
|
60
|
+
IAdminVariant,
|
|
61
|
+
IExperimentEntry,
|
|
62
|
+
IExperimentResults,
|
|
63
|
+
INewExperiment,
|
|
64
|
+
IResultsMetric,
|
|
65
|
+
IResultsVariant,
|
|
66
|
+
TExperimentAction,
|
|
67
|
+
TExperimentActionResult,
|
|
68
|
+
TExperimentStatus,
|
|
69
|
+
TLifecycleAction,
|
|
70
|
+
TVersionAction,
|
|
71
|
+
} from './types';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The experiment and results objects of the qorus-api admin and editor routes (§5, §7, §9.5 of
|
|
3
|
+
* `design/analytics-and-experiments-design.md`), with the fields the A/B test UI reads.
|
|
4
|
+
* Change the contract there first, then here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type TExperimentStatus =
|
|
8
|
+
'draft' | 'running' | 'paused' | 'stopped' | 'concluded' | 'archived';
|
|
9
|
+
|
|
10
|
+
export interface IAdminVariant {
|
|
11
|
+
key: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
weight: number;
|
|
14
|
+
control?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IAdminExperimentPhase {
|
|
18
|
+
phase: number;
|
|
19
|
+
started_at: string;
|
|
20
|
+
ended_at: string | null;
|
|
21
|
+
variants: IAdminVariant[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IAdminExperimentEvent {
|
|
25
|
+
at: string;
|
|
26
|
+
action: string;
|
|
27
|
+
variant?: string | null;
|
|
28
|
+
/** Who did it (`admin`, or the product's editor, e.g. `landing-editor`). */
|
|
29
|
+
actor: string;
|
|
30
|
+
user?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The admin experiment object. */
|
|
34
|
+
export interface IAdminExperiment {
|
|
35
|
+
key: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
property?: string;
|
|
38
|
+
status: TExperimentStatus;
|
|
39
|
+
variants: IAdminVariant[];
|
|
40
|
+
winner?: string | null;
|
|
41
|
+
/** The current phase: a removed version starts a new one, and the numbers count from zero. */
|
|
42
|
+
phase?: number;
|
|
43
|
+
/** Earlier phases and the current one (last, `ended_at: null` while it runs). */
|
|
44
|
+
phases?: IAdminExperimentPhase[];
|
|
45
|
+
/** Every state change, oldest first. */
|
|
46
|
+
history?: IAdminExperimentEvent[];
|
|
47
|
+
metrics?: { primary?: string; guardrails?: string[]; secondary?: string[] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface IResultsVariant {
|
|
51
|
+
variant: string;
|
|
52
|
+
exposed: number;
|
|
53
|
+
converters: number;
|
|
54
|
+
rate: number | null;
|
|
55
|
+
uplift?: number | null;
|
|
56
|
+
chance_to_beat: number | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface IResultsMetric {
|
|
60
|
+
key: string;
|
|
61
|
+
name?: string;
|
|
62
|
+
role: 'primary' | 'guardrail' | 'secondary';
|
|
63
|
+
verdict?: string;
|
|
64
|
+
verdict_text?: string;
|
|
65
|
+
variants: IResultsVariant[];
|
|
66
|
+
unavailable?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The admin results object. */
|
|
70
|
+
export interface IExperimentResults {
|
|
71
|
+
verdict?: string;
|
|
72
|
+
/** The verdict in plain words, written by the server. */
|
|
73
|
+
verdict_text?: string;
|
|
74
|
+
winner_variant?: string | null;
|
|
75
|
+
phase?: number;
|
|
76
|
+
metrics: IResultsMetric[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One entry of the editor list: the experiment and its results (null for a draft). */
|
|
80
|
+
export interface IExperimentEntry {
|
|
81
|
+
experiment: IAdminExperiment;
|
|
82
|
+
results: IExperimentResults | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The experiment an action returns; `auto_accepted` when removing left one version. */
|
|
86
|
+
export type TExperimentActionResult = IAdminExperiment & { auto_accepted?: boolean };
|
|
87
|
+
|
|
88
|
+
export type TLifecycleAction = 'start' | 'pause' | 'stop';
|
|
89
|
+
export type TVersionAction = 'accept' | 'remove';
|
|
90
|
+
export type TExperimentAction = TVersionAction | TLifecycleAction;
|
|
91
|
+
|
|
92
|
+
/** A new experiment definition (admin shape); the server always creates it as a draft. */
|
|
93
|
+
export interface INewExperiment {
|
|
94
|
+
key: string;
|
|
95
|
+
name: string;
|
|
96
|
+
hypothesis?: string;
|
|
97
|
+
property?: string;
|
|
98
|
+
variants: IAdminVariant[];
|
|
99
|
+
traffic?: number;
|
|
100
|
+
targeting?: { paths?: string[] | null; device?: string | null; lang?: string | null };
|
|
101
|
+
trigger?: { section?: string | null };
|
|
102
|
+
metrics: { primary: string; guardrails?: string[]; secondary?: string[] };
|
|
103
|
+
min_sample?: { per_variant: number; days: number };
|
|
104
|
+
}
|