@appeeky/spec 0.0.0-stage → 0.2.0
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/.github/assets/cover.gif +0 -0
- package/LICENSE +110 -0
- package/README.md +636 -2
- package/agents/appeeky-ads.md +50 -0
- package/agents/appeeky-aso.md +58 -0
- package/agents/appeeky-releases.md +47 -0
- package/agents/appeeky-reviews.md +53 -0
- package/agents/appeeky-social.md +54 -0
- package/agents/appeeky-ugc.md +56 -0
- package/agents/cofounder.md +36 -0
- package/dist/ads.d.ts +42 -0
- package/dist/ads.js +71 -0
- package/dist/agents.d.ts +47 -0
- package/dist/agents.js +88 -0
- package/dist/appeeky-api.d.ts +62 -0
- package/dist/appeeky-api.js +240 -0
- package/dist/attribution-setup.d.ts +28 -0
- package/dist/attribution-setup.js +65 -0
- package/dist/attribution.d.ts +121 -0
- package/dist/attribution.js +170 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/category.d.ts +71 -0
- package/dist/category.js +198 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1960 -0
- package/dist/competitors.d.ts +74 -0
- package/dist/competitors.js +143 -0
- package/dist/control.d.ts +21 -0
- package/dist/control.js +53 -0
- package/dist/core.d.ts +15 -0
- package/dist/core.js +15 -0
- package/dist/coverage.d.ts +46 -0
- package/dist/coverage.js +115 -0
- package/dist/data.d.ts +48 -0
- package/dist/data.js +298 -0
- package/dist/dataforseo.d.ts +139 -0
- package/dist/dataforseo.js +354 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +33 -0
- package/dist/experiments.d.ts +115 -0
- package/dist/experiments.js +85 -0
- package/dist/files.d.ts +27 -0
- package/dist/files.js +129 -0
- package/dist/history.d.ts +43 -0
- package/dist/history.js +55 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/limits.d.ts +15 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +16 -0
- package/dist/lint.js +159 -0
- package/dist/loops.d.ts +37 -0
- package/dist/loops.js +197 -0
- package/dist/measure.d.ts +40 -0
- package/dist/measure.js +159 -0
- package/dist/metrics.d.ts +290 -0
- package/dist/metrics.js +254 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +79 -0
- package/dist/project.d.ts +19 -0
- package/dist/project.js +35 -0
- package/dist/report.d.ts +10 -0
- package/dist/report.js +113 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +612 -0
- package/dist/schema.js +253 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/search-ads.d.ts +137 -0
- package/dist/search-ads.js +354 -0
- package/dist/seo.d.ts +87 -0
- package/dist/seo.js +391 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +259 -0
- package/dist/social.d.ts +129 -0
- package/dist/social.js +189 -0
- package/dist/storefronts.d.ts +15 -0
- package/dist/storefronts.js +89 -0
- package/dist/stores/app-store.d.ts +33 -0
- package/dist/stores/app-store.js +159 -0
- package/dist/stores/google-play.d.ts +13 -0
- package/dist/stores/google-play.js +61 -0
- package/dist/stores/types.d.ts +34 -0
- package/dist/stores/types.js +2 -0
- package/dist/suggest.d.ts +73 -0
- package/dist/suggest.js +135 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +55 -0
- package/dist/transport.d.ts +99 -0
- package/dist/transport.js +170 -0
- package/dist/ugc-core.js +1 -0
- package/dist/ugc-local.d.ts +116 -0
- package/dist/ugc-local.js +174 -0
- package/dist/ugc.d.ts +149 -0
- package/dist/ugc.js +111 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility-core.js +96 -0
- package/dist/visibility-local.d.ts +159 -0
- package/dist/visibility-local.js +246 -0
- package/dist/visibility.d.ts +152 -0
- package/dist/visibility.js +239 -0
- package/package.json +80 -4
- package/skills/appeeky-spec/SKILL.md +142 -0
- package/skills/appeeky-spec-experiments/SKILL.md +93 -0
- package/skills/appeeky-spec-localize/SKILL.md +52 -0
- package/skills/appeeky-spec-review/SKILL.md +75 -0
- package/skills/appeeky-spec-targets/SKILL.md +66 -0
- package/skills/appeeky-spec-visibility/SKILL.md +106 -0
package/dist/loops.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readChanges } from "./competitors.js";
|
|
4
|
+
import { readExperiments } from "./experiments.js";
|
|
5
|
+
import { SPEC_DIR } from "./files.js";
|
|
6
|
+
import { readReleases } from "./history.js";
|
|
7
|
+
import { CHECK_DAYS } from "./measure.js";
|
|
8
|
+
import { formatChange, METRICS } from "./metrics.js";
|
|
9
|
+
/**
|
|
10
|
+
* Every measurement loop in the repo, in one place: each listing push (a release) and each
|
|
11
|
+
* experiment, what it measures, when it is checked next and what it shows so far. Written to
|
|
12
|
+
* appeeky/loops.md by the commands that open, measure or close a loop, and shown by `status`.
|
|
13
|
+
*/
|
|
14
|
+
const DAY = 86_400_000;
|
|
15
|
+
/** A check this many days past its date with no result has stalled (CI not running, a key missing). */
|
|
16
|
+
export const LATE_AFTER_DAYS = 2;
|
|
17
|
+
/** Fewer installs than this in a window: the numbers are a hint, not a result. */
|
|
18
|
+
export const FEW_INSTALLS = 100;
|
|
19
|
+
const iso = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
20
|
+
function stateFor(nextCheckMs, now) {
|
|
21
|
+
if (nextCheckMs === undefined)
|
|
22
|
+
return "closed";
|
|
23
|
+
if (now.getTime() < nextCheckMs)
|
|
24
|
+
return "measuring";
|
|
25
|
+
return now.getTime() >= nextCheckMs + LATE_AFTER_DAYS * DAY ? "late" : "due";
|
|
26
|
+
}
|
|
27
|
+
function releaseWhat(r) {
|
|
28
|
+
const fields = [...new Set(r.changes.map((c) => `${c.locale} ${c.field}`))];
|
|
29
|
+
const head = fields.slice(0, 3).join(", ") + (fields.length > 3 ? ` +${fields.length - 3}` : "");
|
|
30
|
+
return r.note ? `${head}: ${r.note}` : head || r.target;
|
|
31
|
+
}
|
|
32
|
+
/** Installs per window behind a release's latest check, when it has them. */
|
|
33
|
+
function windowInstalls(check) {
|
|
34
|
+
const m = check.metrics;
|
|
35
|
+
if (!m)
|
|
36
|
+
return undefined;
|
|
37
|
+
const per = (w) => (w.values.organic_installs ?? w.values.downloads);
|
|
38
|
+
const b = per(m.before);
|
|
39
|
+
const a = per(m.after);
|
|
40
|
+
if (b === undefined || a === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
return Math.round(Math.min(b * m.before.days, a * m.after.days));
|
|
43
|
+
}
|
|
44
|
+
export function releaseLoop(r, now = new Date()) {
|
|
45
|
+
const done = new Set((r.checks ?? []).map((c) => c.day));
|
|
46
|
+
const next = r.baseline ? CHECK_DAYS.find((d) => !done.has(d)) : undefined;
|
|
47
|
+
const nextMs = next === undefined ? undefined : Date.parse(r.pushedAt) + next * DAY;
|
|
48
|
+
const last = r.checks?.at(-1);
|
|
49
|
+
const warnings = [];
|
|
50
|
+
if (!r.baseline)
|
|
51
|
+
warnings.push("no baseline was saved at push: it cannot be measured");
|
|
52
|
+
const installs = last ? windowInstalls(last) : undefined;
|
|
53
|
+
if (installs !== undefined && installs < FEW_INSTALLS)
|
|
54
|
+
warnings.push(`${installs} installs in the window: treat the result as a hint`);
|
|
55
|
+
return {
|
|
56
|
+
kind: "release",
|
|
57
|
+
id: r.id,
|
|
58
|
+
store: r.store,
|
|
59
|
+
what: releaseWhat(r),
|
|
60
|
+
metric: last?.decidedBy ? last.decidedBy.map((m) => METRICS[m]?.label ?? m).join(", ") : "target ranks",
|
|
61
|
+
startedAt: r.pushedAt,
|
|
62
|
+
...(nextMs !== undefined ? { nextCheck: iso(nextMs) } : {}),
|
|
63
|
+
state: r.baseline ? stateFor(nextMs, now) : "closed",
|
|
64
|
+
...(last ? { result: `day ${last.day} ${last.verdict}${last.decidedBy ? " by outcomes" : ""}` } : {}),
|
|
65
|
+
...(last
|
|
66
|
+
? {
|
|
67
|
+
detail: [
|
|
68
|
+
...(last.improved.length || last.dropped.length ? [`${last.improved.length} ${last.improved.length === 1 ? "target" : "targets"} up, ${last.dropped.length} down`] : []),
|
|
69
|
+
...(last.control ? [`net ${last.control.net > 0 ? "+" : ""}${last.control.net} places vs unchanged storefronts`] : []),
|
|
70
|
+
...(last.note ? [last.note] : []),
|
|
71
|
+
...(last.metrics?.changes ?? []).filter((c) => c.outcome !== "flat").slice(0, 3).map((c) => formatChange(c, last.metrics?.after.currency)),
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
: {}),
|
|
75
|
+
warnings,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function experimentLoop(e, now = new Date()) {
|
|
79
|
+
const running = e.status === "running";
|
|
80
|
+
const nextMs = running ? Date.parse(e.startedAt) + e.checkAfterDays * DAY : undefined;
|
|
81
|
+
const value = e.result?.value;
|
|
82
|
+
const goal = e.target !== undefined ? `target ${e.target}` : e.baseline !== undefined ? `baseline ${round(e.baseline)}` : "";
|
|
83
|
+
return {
|
|
84
|
+
kind: "experiment",
|
|
85
|
+
id: e.id,
|
|
86
|
+
...(e.store ? { store: e.store } : {}),
|
|
87
|
+
what: e.hypothesis,
|
|
88
|
+
metric: METRICS[e.metric]?.label ?? e.metric,
|
|
89
|
+
startedAt: e.startedAt,
|
|
90
|
+
...(nextMs !== undefined ? { nextCheck: iso(nextMs) } : {}),
|
|
91
|
+
state: stateFor(nextMs, now),
|
|
92
|
+
...(running ? {} : { result: `${e.status}${value !== undefined ? ` (${round(value)}${goal ? ` vs ${goal}` : ""})` : ""}` }),
|
|
93
|
+
...(running && goal ? { detail: [goal] } : {}),
|
|
94
|
+
warnings: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const round = (n) => (Math.abs(n) >= 100 ? Math.round(n) : Math.round(n * 1000) / 1000);
|
|
98
|
+
/**
|
|
99
|
+
* Releases on the same store pushed within the measuring span of each other are measured
|
|
100
|
+
* over the same days, so their results mix. Both get a warning while either is open.
|
|
101
|
+
*/
|
|
102
|
+
function markOverlaps(releases, loops) {
|
|
103
|
+
const span = Math.max(...CHECK_DAYS) * DAY;
|
|
104
|
+
for (const a of releases)
|
|
105
|
+
for (const b of releases) {
|
|
106
|
+
if (a.id >= b.id || a.store !== b.store)
|
|
107
|
+
continue;
|
|
108
|
+
if (Math.abs(Date.parse(a.pushedAt) - Date.parse(b.pushedAt)) >= span)
|
|
109
|
+
continue;
|
|
110
|
+
const la = loops.get(a.id);
|
|
111
|
+
const lb = loops.get(b.id);
|
|
112
|
+
if (la.state === "closed" && lb.state === "closed")
|
|
113
|
+
continue;
|
|
114
|
+
la.warnings.push(`measured over the same days as ${b.id}: the results mix`);
|
|
115
|
+
lb.warnings.push(`measured over the same days as ${a.id}: the results mix`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const ORDER = { late: 0, due: 1, measuring: 2, closed: 3 };
|
|
119
|
+
export async function collectLoops(root, now = new Date()) {
|
|
120
|
+
const releases = await readReleases(root);
|
|
121
|
+
const experiments = await readExperiments(root);
|
|
122
|
+
const byId = new Map();
|
|
123
|
+
for (const r of releases)
|
|
124
|
+
byId.set(r.id, releaseLoop(r, now));
|
|
125
|
+
markOverlaps(releases, byId);
|
|
126
|
+
// A competitor's change inside a release's window moves the same numbers.
|
|
127
|
+
const changes = await readChanges(root);
|
|
128
|
+
const span = Math.max(...CHECK_DAYS) * DAY;
|
|
129
|
+
for (const r of releases) {
|
|
130
|
+
const loop = byId.get(r.id);
|
|
131
|
+
if (loop.state === "closed")
|
|
132
|
+
continue;
|
|
133
|
+
const from = Date.parse(r.pushedAt) - span;
|
|
134
|
+
const to = Date.parse(r.pushedAt) + span;
|
|
135
|
+
for (const ch of changes) {
|
|
136
|
+
const t = Date.parse(ch.date);
|
|
137
|
+
if (t >= from && t <= to && (ch.change.startsWith(r.store === "app-store" ? "App Store" : "Google Play")))
|
|
138
|
+
loop.warnings.push(`${ch.name} changed on ${ch.date}, inside this window: ${ch.change}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const loops = [...byId.values(), ...experiments.map((e) => experimentLoop(e, now))];
|
|
142
|
+
return loops.sort((a, b) => ORDER[a.state] - ORDER[b.state] || (a.nextCheck ?? "9").localeCompare(b.nextCheck ?? "9") || b.startedAt.localeCompare(a.startedAt));
|
|
143
|
+
}
|
|
144
|
+
const STATE_LABEL = { late: "late", due: "due", measuring: "measuring", closed: "closed" };
|
|
145
|
+
const cell = (s) => s.replace(/\|/g, "\\|").replace(/\n+/g, " ");
|
|
146
|
+
/** appeeky/loops.md: what is being measured, what is due, what came out. Rewritten, not appended. */
|
|
147
|
+
export function loopsMarkdown(loops, now = new Date()) {
|
|
148
|
+
const open = loops.filter((l) => l.state !== "closed");
|
|
149
|
+
const closed = loops.filter((l) => l.state === "closed").slice(0, 20);
|
|
150
|
+
const lines = [
|
|
151
|
+
"# Loops",
|
|
152
|
+
"",
|
|
153
|
+
`Every change being measured, generated by appeeky-spec on ${iso(now.getTime())}. Do not edit: run \`appeeky-spec status\`.`,
|
|
154
|
+
"Releases are checked on day 7 and day 14 (`verify`); experiments on their check date (`verify`, `experiment close`).",
|
|
155
|
+
"",
|
|
156
|
+
"## Open",
|
|
157
|
+
"",
|
|
158
|
+
];
|
|
159
|
+
if (open.length === 0)
|
|
160
|
+
lines.push("Nothing is being measured.");
|
|
161
|
+
else {
|
|
162
|
+
lines.push("| State | Next check | Kind | What | Decided by | So far |", "| --- | --- | --- | --- | --- | --- |");
|
|
163
|
+
for (const l of open)
|
|
164
|
+
lines.push(`| ${STATE_LABEL[l.state]} | ${l.nextCheck ?? "—"} | ${l.kind}${l.store ? ` · ${l.store}` : ""} | ${cell(l.what)} | ${cell(l.metric)} | ${cell([l.result, ...(l.detail ?? [])].filter(Boolean).join("; ") || "—")} |`);
|
|
165
|
+
}
|
|
166
|
+
const warned = loops.filter((l) => l.warnings.length && l.state !== "closed");
|
|
167
|
+
if (warned.length) {
|
|
168
|
+
lines.push("", "## Watch", "");
|
|
169
|
+
for (const l of warned)
|
|
170
|
+
for (const w of l.warnings)
|
|
171
|
+
lines.push(`- \`${l.id}\`: ${w}`);
|
|
172
|
+
}
|
|
173
|
+
lines.push("", "## Closed", "");
|
|
174
|
+
if (closed.length === 0)
|
|
175
|
+
lines.push("Nothing closed yet.");
|
|
176
|
+
else {
|
|
177
|
+
lines.push("| Closed loop | What | Result |", "| --- | --- | --- |");
|
|
178
|
+
for (const l of closed)
|
|
179
|
+
lines.push(`| \`${l.id}\` | ${cell(l.what)} | ${cell([l.result, ...(l.detail ?? [])].filter(Boolean).join("; ") || "—")} |`);
|
|
180
|
+
}
|
|
181
|
+
return lines.join("\n") + "\n";
|
|
182
|
+
}
|
|
183
|
+
export async function writeLoops(root, now = new Date()) {
|
|
184
|
+
const loops = await collectLoops(root, now);
|
|
185
|
+
await mkdir(join(root, SPEC_DIR), { recursive: true });
|
|
186
|
+
await writeFile(join(root, SPEC_DIR, "loops.md"), loopsMarkdown(loops, now));
|
|
187
|
+
return loops;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Stores with no release being measured: a new listing change there will not mix with an
|
|
191
|
+
* open one. This is what a scheduled agent checks before it proposes anything.
|
|
192
|
+
*/
|
|
193
|
+
export function freeStores(loops, stores) {
|
|
194
|
+
const busy = new Set(loops.filter((l) => l.kind === "release" && l.state !== "closed").map((l) => l.store));
|
|
195
|
+
return stores.filter((s) => !busy.has(s));
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=loops.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Experiment } from "./experiments.js";
|
|
2
|
+
import { type ReleaseRecord } from "./history.js";
|
|
3
|
+
import type { Project, StoreId } from "./schema.js";
|
|
4
|
+
import { type MetricSource } from "./metrics.js";
|
|
5
|
+
import { type ConversionSource, type RankSource } from "./verify.js";
|
|
6
|
+
/** Days after a push when a release is measured. */
|
|
7
|
+
export declare const CHECK_DAYS: readonly [7, 14];
|
|
8
|
+
export interface VerifyResult {
|
|
9
|
+
release: ReleaseRecord;
|
|
10
|
+
check: NonNullable<ReleaseRecord["checks"]>[number];
|
|
11
|
+
}
|
|
12
|
+
/** The next milestone a release is due for, or null when it is not due (or already checked). */
|
|
13
|
+
export declare function dueMilestone(record: ReleaseRecord, now?: Date): number | null;
|
|
14
|
+
/**
|
|
15
|
+
* Measure releases against their baseline ranks and write the result into
|
|
16
|
+
* each release file. By default only releases that reached a milestone
|
|
17
|
+
* (day 7, day 14) are measured; `force` measures the given ones now.
|
|
18
|
+
*/
|
|
19
|
+
export declare function verifyReleases(project: Project, source: RankSource, options?: {
|
|
20
|
+
ids?: string[];
|
|
21
|
+
force?: boolean;
|
|
22
|
+
now?: Date;
|
|
23
|
+
conversion?: ConversionSource;
|
|
24
|
+
metrics?: MetricSource;
|
|
25
|
+
}): Promise<VerifyResult[]>;
|
|
26
|
+
/** One playbook line: what changed, the hypothesis, and what the ranks did. */
|
|
27
|
+
export declare function playbookLine(result: VerifyResult): string;
|
|
28
|
+
/** Append lines to appeeky/playbook.md, skipping ones already there. */
|
|
29
|
+
export declare function appendPlaybook(root: string, lines: string[]): Promise<number>;
|
|
30
|
+
/** The app an experiment is about: its store, or the first store in the spec. */
|
|
31
|
+
export declare function experimentApp(project: Project, e: Pick<Experiment, "store">): {
|
|
32
|
+
store: StoreId;
|
|
33
|
+
appId: string;
|
|
34
|
+
} | null;
|
|
35
|
+
/**
|
|
36
|
+
* Close due experiments whose metric Appeeky can measure (conversion, revenue,
|
|
37
|
+
* trials, cpa, ...): the value is the metric over the experiment's run.
|
|
38
|
+
* Experiments on other metrics stay due for `experiment close --value`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function measureExperiments(project: Project, source: MetricSource, now?: Date): Promise<Experiment[]>;
|
package/dist/measure.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { SPEC_DIR } from "./files.js";
|
|
5
|
+
import { closeExperiment, dueExperiments, readExperiments, writeExperiment } from "./experiments.js";
|
|
6
|
+
import { readReleases, releaseAge, updateRelease } from "./history.js";
|
|
7
|
+
import { formatChange, isMetricName, measureAround, metricAfter, outcomeVerdict } from "./metrics.js";
|
|
8
|
+
import { compareWithControl, MIN_NET_PLACES } from "./control.js";
|
|
9
|
+
import { evaluateRanks, measureConversion, rankAppIds } from "./verify.js";
|
|
10
|
+
/** Days after a push when a release is measured. */
|
|
11
|
+
export const CHECK_DAYS = [7, 14];
|
|
12
|
+
/** The next milestone a release is due for, or null when it is not due (or already checked). */
|
|
13
|
+
export function dueMilestone(record, now = new Date()) {
|
|
14
|
+
if (!record.baseline)
|
|
15
|
+
return null;
|
|
16
|
+
const age = releaseAge(record, now);
|
|
17
|
+
const done = new Set((record.checks ?? []).map((c) => c.day));
|
|
18
|
+
const due = CHECK_DAYS.filter((d) => age >= d && !done.has(d));
|
|
19
|
+
return due.length > 0 ? due.at(-1) : null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Measure releases against their baseline ranks and write the result into
|
|
23
|
+
* each release file. By default only releases that reached a milestone
|
|
24
|
+
* (day 7, day 14) are measured; `force` measures the given ones now.
|
|
25
|
+
*/
|
|
26
|
+
export async function verifyReleases(project, source, options = {}) {
|
|
27
|
+
const now = options.now ?? new Date();
|
|
28
|
+
const ids = rankAppIds(project);
|
|
29
|
+
const releases = (await readReleases(project.root)).filter((r) => !options.ids || options.ids.some((id) => r.id.startsWith(id)));
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const release of releases) {
|
|
32
|
+
if (!release.baseline)
|
|
33
|
+
continue;
|
|
34
|
+
const day = options.force ? releaseAge(release, now) : dueMilestone(release, now);
|
|
35
|
+
if (day === null)
|
|
36
|
+
continue;
|
|
37
|
+
const appId = ids[release.store];
|
|
38
|
+
if (!appId)
|
|
39
|
+
continue;
|
|
40
|
+
const current = {};
|
|
41
|
+
for (const [country, ranks] of Object.entries(release.baseline.ranks)) {
|
|
42
|
+
try {
|
|
43
|
+
current[country] = await source.ranks(release.store, appId, country, Object.keys(ranks));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// leave the storefront out of this check
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const span = Math.min(Math.max(day, 1), 14);
|
|
50
|
+
const metrics = options.metrics ? await measureAround(options.metrics, release.store, appId, release.pushedAt, span).catch(() => null) : null;
|
|
51
|
+
const conversion = options.conversion
|
|
52
|
+
? await measureConversion(options.conversion, release.store, appId, release.pushedAt, span).catch(() => null)
|
|
53
|
+
: metrics
|
|
54
|
+
? conversionFromMetrics(metrics)
|
|
55
|
+
: null;
|
|
56
|
+
const evaluation = evaluateRanks(release.baseline.ranks, current);
|
|
57
|
+
// Unchanged storefronts as a control: a store-wide lift is not the release's win.
|
|
58
|
+
const control = compareWithControl(project, release, release.baseline.ranks, current);
|
|
59
|
+
if (control) {
|
|
60
|
+
evaluation.control = control;
|
|
61
|
+
if (evaluation.verdict === "won" && control.net < MIN_NET_PLACES) {
|
|
62
|
+
evaluation.verdict = "mixed";
|
|
63
|
+
evaluation.note = `the unchanged storefronts (${control.control.join(", ")}) moved as much: the store moved, not the listing`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Ranks are a means; when none moved, organic installs and revenue (or conversion) decide.
|
|
67
|
+
const byOutcome = evaluation.verdict === "flat" && metrics ? outcomeVerdict(metrics.changes) : null;
|
|
68
|
+
if (byOutcome)
|
|
69
|
+
Object.assign(evaluation, { verdict: byOutcome.verdict, decidedBy: byOutcome.by });
|
|
70
|
+
const check = {
|
|
71
|
+
at: now.toISOString(),
|
|
72
|
+
day,
|
|
73
|
+
ranks: current,
|
|
74
|
+
...evaluation,
|
|
75
|
+
...(conversion ? { conversion } : {}),
|
|
76
|
+
...(metrics ? { metrics } : {}),
|
|
77
|
+
};
|
|
78
|
+
release.checks = [...(release.checks ?? []).filter((c) => c.day !== day), check].sort((a, b) => a.day - b.day);
|
|
79
|
+
await updateRelease(project.root, release);
|
|
80
|
+
out.push({ release, check });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** The conversion field of a check, from outcome metrics when no conversion source is given. */
|
|
85
|
+
function conversionFromMetrics(m) {
|
|
86
|
+
const window = (w) => {
|
|
87
|
+
const rate = w.values.conversion;
|
|
88
|
+
const views = w.values.page_views;
|
|
89
|
+
if (rate === undefined || views === undefined)
|
|
90
|
+
return null;
|
|
91
|
+
const visitors = Math.round(views * w.days);
|
|
92
|
+
return { rate, visitors, installs: Math.round(rate * visitors), days: w.days };
|
|
93
|
+
};
|
|
94
|
+
const before = window(m.before);
|
|
95
|
+
const after = window(m.after);
|
|
96
|
+
return before && after ? { before, after, change: before.rate > 0 ? after.rate / before.rate - 1 : 0 } : null;
|
|
97
|
+
}
|
|
98
|
+
/** One playbook line: what changed, the hypothesis, and what the ranks did. */
|
|
99
|
+
export function playbookLine(result) {
|
|
100
|
+
const { release, check } = result;
|
|
101
|
+
const fields = [...new Set(release.changes.map((c) => `${c.locale} ${c.field}`))].join(", ");
|
|
102
|
+
const moves = [
|
|
103
|
+
...check.improved.slice(0, 3).map((m) => `"${m.keyword}" ${m.before ?? "–"}→${m.after ?? "–"}`),
|
|
104
|
+
...check.regressions.slice(0, 3).map((m) => `lost "${m.keyword}" ${m.before}→${m.after ?? "out"}`),
|
|
105
|
+
].join("; ");
|
|
106
|
+
// Conversion and the money metrics say whether the listing sold better, not just ranked better.
|
|
107
|
+
const outcomes = (check.metrics?.changes ?? []).filter((m) => m.outcome !== "flat" && ["conversion", "downloads", "revenue", "proceeds", "trials", "rc_trials", "organic_installs", "organic_revenue"].includes(m.metric));
|
|
108
|
+
// The metrics that decided the verdict go first.
|
|
109
|
+
if (check.decidedBy)
|
|
110
|
+
outcomes.sort((a, b) => Number(check.decidedBy.includes(b.metric)) - Number(check.decidedBy.includes(a.metric)));
|
|
111
|
+
const conv = outcomes.length
|
|
112
|
+
? outcomes.slice(0, 3).map((m) => formatChange(m, check.metrics?.after.currency)).join("; ")
|
|
113
|
+
: check.conversion
|
|
114
|
+
? `conversion ${(check.conversion.before.rate * 100).toFixed(1)}% → ${(check.conversion.after.rate * 100).toFixed(1)}%`
|
|
115
|
+
: "";
|
|
116
|
+
const control = check.control ? `net ${check.control.net > 0 ? "+" : ""}${check.control.net} places vs unchanged ${check.control.control.join(", ")}` : "";
|
|
117
|
+
const detail = [moves, control, conv].filter(Boolean).join("; ");
|
|
118
|
+
return `- ${release.pushedAt.slice(0, 10)}, ${release.store}, ${fields}${release.note ? `: ${release.note}` : ""} → day ${check.day} ${check.verdict}${check.decidedBy ? " by outcomes" : ""}${detail ? ` (${detail})` : ""}`;
|
|
119
|
+
}
|
|
120
|
+
/** Append lines to appeeky/playbook.md, skipping ones already there. */
|
|
121
|
+
export async function appendPlaybook(root, lines) {
|
|
122
|
+
const file = join(root, SPEC_DIR, "playbook.md");
|
|
123
|
+
const existing = existsSync(file) ? await readFile(file, "utf8") : "# Playbook\n";
|
|
124
|
+
const fresh = lines.filter((l) => !existing.includes(l));
|
|
125
|
+
if (fresh.length === 0)
|
|
126
|
+
return 0;
|
|
127
|
+
if (!existsSync(file))
|
|
128
|
+
await writeFile(file, existing);
|
|
129
|
+
await appendFile(file, `${existing.endsWith("\n") ? "" : "\n"}${fresh.join("\n")}\n`);
|
|
130
|
+
return fresh.length;
|
|
131
|
+
}
|
|
132
|
+
/** The app an experiment is about: its store, or the first store in the spec. */
|
|
133
|
+
export function experimentApp(project, e) {
|
|
134
|
+
const ids = rankAppIds(project);
|
|
135
|
+
const store = e.store ?? Object.keys(ids)[0];
|
|
136
|
+
const appId = store ? ids[store] : undefined;
|
|
137
|
+
return store && appId ? { store, appId } : null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Close due experiments whose metric Appeeky can measure (conversion, revenue,
|
|
141
|
+
* trials, cpa, ...): the value is the metric over the experiment's run.
|
|
142
|
+
* Experiments on other metrics stay due for `experiment close --value`.
|
|
143
|
+
*/
|
|
144
|
+
export async function measureExperiments(project, source, now = new Date()) {
|
|
145
|
+
const closed = [];
|
|
146
|
+
for (const e of dueExperiments(await readExperiments(project.root), now)) {
|
|
147
|
+
if (!isMetricName(e.metric))
|
|
148
|
+
continue;
|
|
149
|
+
const app = experimentApp(project, e);
|
|
150
|
+
if (!app)
|
|
151
|
+
continue;
|
|
152
|
+
const value = await metricAfter(source, app.store, app.appId, e.metric, e.startedAt, e.checkAfterDays).catch(() => undefined);
|
|
153
|
+
if (value === undefined)
|
|
154
|
+
continue;
|
|
155
|
+
closed.push(await writeExperiment(project.root, closeExperiment(e, value, `Measured by appeeky-spec over ${e.checkAfterDays} days.`, now)));
|
|
156
|
+
}
|
|
157
|
+
return closed;
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=measure.js.map
|