@appeeky/spec 0.0.0-stage → 0.1.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.
Files changed (86) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +483 -2
  4. package/dist/ads.d.ts +39 -0
  5. package/dist/ads.js +67 -0
  6. package/dist/appeeky-api.d.ts +62 -0
  7. package/dist/appeeky-api.js +240 -0
  8. package/dist/attribution-setup.d.ts +28 -0
  9. package/dist/attribution-setup.js +65 -0
  10. package/dist/attribution.d.ts +121 -0
  11. package/dist/attribution.js +170 -0
  12. package/dist/bin.d.ts +2 -0
  13. package/dist/bin.js +4 -0
  14. package/dist/category.d.ts +71 -0
  15. package/dist/category.js +198 -0
  16. package/dist/cli.d.ts +3 -0
  17. package/dist/cli.js +1387 -0
  18. package/dist/competitors.d.ts +74 -0
  19. package/dist/competitors.js +143 -0
  20. package/dist/control.d.ts +21 -0
  21. package/dist/control.js +53 -0
  22. package/dist/core.d.ts +15 -0
  23. package/dist/core.js +15 -0
  24. package/dist/coverage.d.ts +46 -0
  25. package/dist/coverage.js +115 -0
  26. package/dist/data.d.ts +48 -0
  27. package/dist/data.js +298 -0
  28. package/dist/diff.d.ts +11 -0
  29. package/dist/diff.js +33 -0
  30. package/dist/experiments.d.ts +115 -0
  31. package/dist/experiments.js +85 -0
  32. package/dist/files.d.ts +27 -0
  33. package/dist/files.js +129 -0
  34. package/dist/history.d.ts +43 -0
  35. package/dist/history.js +55 -0
  36. package/dist/index.d.ts +24 -0
  37. package/dist/index.js +24 -0
  38. package/dist/limits.d.ts +15 -0
  39. package/dist/limits.js +30 -0
  40. package/dist/lint.d.ts +16 -0
  41. package/dist/lint.js +159 -0
  42. package/dist/loops.d.ts +37 -0
  43. package/dist/loops.js +197 -0
  44. package/dist/measure.d.ts +40 -0
  45. package/dist/measure.js +159 -0
  46. package/dist/metrics.d.ts +270 -0
  47. package/dist/metrics.js +250 -0
  48. package/dist/output.d.ts +21 -0
  49. package/dist/output.js +79 -0
  50. package/dist/project.d.ts +19 -0
  51. package/dist/project.js +35 -0
  52. package/dist/report.d.ts +5 -0
  53. package/dist/report.js +71 -0
  54. package/dist/reviews.d.ts +130 -0
  55. package/dist/reviews.js +287 -0
  56. package/dist/schema.d.ts +519 -0
  57. package/dist/schema.js +218 -0
  58. package/dist/screenshots.d.ts +23 -0
  59. package/dist/screenshots.js +116 -0
  60. package/dist/setup.d.ts +45 -0
  61. package/dist/setup.js +226 -0
  62. package/dist/storefronts.d.ts +15 -0
  63. package/dist/storefronts.js +89 -0
  64. package/dist/stores/app-store.d.ts +33 -0
  65. package/dist/stores/app-store.js +159 -0
  66. package/dist/stores/google-play.d.ts +13 -0
  67. package/dist/stores/google-play.js +61 -0
  68. package/dist/stores/types.d.ts +34 -0
  69. package/dist/stores/types.js +2 -0
  70. package/dist/suggest.d.ts +73 -0
  71. package/dist/suggest.js +135 -0
  72. package/dist/text.d.ts +18 -0
  73. package/dist/text.js +55 -0
  74. package/dist/transport.d.ts +98 -0
  75. package/dist/transport.js +170 -0
  76. package/dist/verify.d.ts +70 -0
  77. package/dist/verify.js +134 -0
  78. package/dist/visibility.d.ts +149 -0
  79. package/dist/visibility.js +239 -0
  80. package/package.json +76 -4
  81. package/skills/appeeky-spec/SKILL.md +146 -0
  82. package/skills/appeeky-spec-experiments/SKILL.md +83 -0
  83. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  84. package/skills/appeeky-spec-review/SKILL.md +75 -0
  85. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  86. package/skills/appeeky-spec-visibility/SKILL.md +79 -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[]>;
@@ -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
@@ -0,0 +1,270 @@
1
+ import type { StoreId } from "./schema.js";
2
+ /**
3
+ * Business outcomes of a change, beyond search ranks: conversion, downloads,
4
+ * revenue, trials and paid installs. They come from the store analytics,
5
+ * Apple Search Ads and RevenueCat data a user has connected in Appeeky
6
+ * (`GET /v1/user/apps/:appId/metrics`), so they need APPEEKY_API_KEY.
7
+ */
8
+ export declare const METRICS: {
9
+ readonly conversion: {
10
+ readonly label: "conversion";
11
+ readonly kind: "rate";
12
+ readonly better: "up";
13
+ };
14
+ readonly page_views: {
15
+ readonly label: "page views/day";
16
+ readonly kind: "count";
17
+ readonly better: "up";
18
+ };
19
+ readonly downloads: {
20
+ readonly label: "downloads/day";
21
+ readonly kind: "count";
22
+ readonly better: "up";
23
+ };
24
+ readonly proceeds: {
25
+ readonly label: "proceeds/day";
26
+ readonly kind: "money";
27
+ readonly better: "up";
28
+ };
29
+ readonly trials: {
30
+ readonly label: "trials/day";
31
+ readonly kind: "count";
32
+ readonly better: "up";
33
+ };
34
+ readonly revenue: {
35
+ readonly label: "revenue/day";
36
+ readonly kind: "money";
37
+ readonly better: "up";
38
+ };
39
+ readonly rc_trials: {
40
+ readonly label: "RevenueCat trials/day";
41
+ readonly kind: "count";
42
+ readonly better: "up";
43
+ };
44
+ readonly trial_conversion: {
45
+ readonly label: "trial conversion";
46
+ readonly kind: "rate";
47
+ readonly better: "up";
48
+ };
49
+ readonly ad_installs: {
50
+ readonly label: "ad installs/day";
51
+ readonly kind: "count";
52
+ readonly better: "up";
53
+ };
54
+ readonly cpa: {
55
+ readonly label: "cost per ad install";
56
+ readonly kind: "money";
57
+ readonly better: "down";
58
+ };
59
+ readonly rating: {
60
+ readonly label: "average rating";
61
+ readonly kind: "count";
62
+ readonly better: "up";
63
+ };
64
+ readonly organic_installs: {
65
+ readonly label: "organic installs/day";
66
+ readonly kind: "count";
67
+ readonly better: "up";
68
+ };
69
+ readonly organic_revenue: {
70
+ readonly label: "organic revenue/day";
71
+ readonly kind: "money";
72
+ readonly better: "up";
73
+ };
74
+ readonly organic_paid: {
75
+ readonly label: "organic installs that paid";
76
+ readonly kind: "rate";
77
+ readonly better: "up";
78
+ };
79
+ readonly link_clicks: {
80
+ readonly label: "clicks on the experiment's link";
81
+ readonly kind: "count";
82
+ readonly better: "up";
83
+ };
84
+ readonly link_installs: {
85
+ readonly label: "installs from the experiment's link";
86
+ readonly kind: "count";
87
+ readonly better: "up";
88
+ };
89
+ readonly link_revenue: {
90
+ readonly label: "revenue from the experiment's link";
91
+ readonly kind: "money";
92
+ readonly better: "up";
93
+ };
94
+ readonly low_ratings: {
95
+ readonly label: "1–2 star reviews";
96
+ readonly kind: "rate";
97
+ readonly better: "down";
98
+ };
99
+ readonly meta_spend: {
100
+ readonly label: "Meta spend/day";
101
+ readonly kind: "money";
102
+ readonly better: "down";
103
+ };
104
+ readonly meta_installs: {
105
+ readonly label: "Meta installs/day";
106
+ readonly kind: "count";
107
+ readonly better: "up";
108
+ };
109
+ readonly meta_cpi: {
110
+ readonly label: "Meta cost per install";
111
+ readonly kind: "money";
112
+ readonly better: "down";
113
+ };
114
+ readonly tiktok_spend: {
115
+ readonly label: "TikTok spend/day";
116
+ readonly kind: "money";
117
+ readonly better: "down";
118
+ };
119
+ readonly tiktok_conversions: {
120
+ readonly label: "TikTok conversions/day";
121
+ readonly kind: "count";
122
+ readonly better: "up";
123
+ };
124
+ readonly tiktok_cpa: {
125
+ readonly label: "TikTok cost per conversion";
126
+ readonly kind: "money";
127
+ readonly better: "down";
128
+ };
129
+ readonly ai_visibility: {
130
+ readonly label: "AI answers that name the app";
131
+ readonly kind: "rate";
132
+ readonly better: "up";
133
+ };
134
+ readonly category_rank: {
135
+ readonly label: "category rank";
136
+ readonly kind: "count";
137
+ readonly better: "down";
138
+ };
139
+ };
140
+ export type MetricName = keyof typeof METRICS;
141
+ export declare function isMetricName(name: string): name is MetricName;
142
+ /** Metric values over a date range: counts and money per day, rates over the whole range. */
143
+ export interface MetricWindow {
144
+ from: string;
145
+ /** Exclusive end. */
146
+ to: string;
147
+ days: number;
148
+ values: Partial<Record<MetricName, number>>;
149
+ currency?: string;
150
+ }
151
+ /** Where outcome metrics come from. */
152
+ export interface MetricSource {
153
+ readonly label: string;
154
+ /** Metrics between two ISO dates (inclusive start, exclusive end); null when there is no data. */
155
+ window(store: StoreId, appId: string, from: string, to: string): Promise<MetricWindow | null>;
156
+ }
157
+ /** The part of the Appeeky API response this module reads. */
158
+ export interface AppMetricsResponse {
159
+ store: {
160
+ days: Array<{
161
+ date: string;
162
+ pageViews: number | null;
163
+ downloads: number | null;
164
+ proceeds: number | null;
165
+ trials: number | null;
166
+ }>;
167
+ } | null;
168
+ ads: {
169
+ currency: string | null;
170
+ days: Array<{
171
+ date: string;
172
+ spend: number;
173
+ installs: number;
174
+ }>;
175
+ } | null;
176
+ revenuecat: {
177
+ scope: "app" | "project";
178
+ currency: string | null;
179
+ days: Array<{
180
+ date: string;
181
+ revenue: number | null;
182
+ trials: number | null;
183
+ trialConversion: number | null;
184
+ }>;
185
+ } | null;
186
+ }
187
+ /** Below this many product page views in a window, a conversion rate is noise. */
188
+ export declare const MIN_PAGE_VIEWS = 50;
189
+ /**
190
+ * Sum one API response into a window. RevenueCat numbers are only used when
191
+ * the RevenueCat project holds just this app; project-wide numbers would
192
+ * credit a listing change with another app's revenue.
193
+ */
194
+ export declare function summarizeMetrics(data: AppMetricsResponse, from: string, to: string): MetricWindow;
195
+ export interface MetricChange {
196
+ metric: MetricName;
197
+ before: number;
198
+ after: number;
199
+ /** Relative change, e.g. 0.12 = +12%. */
200
+ change: number;
201
+ /** Whether the move is good for the app, given which direction is better. */
202
+ outcome: "better" | "worse" | "flat";
203
+ /**
204
+ * How much this metric normally moves between two windows of the same length before the
205
+ * change (twice the spread of past window-to-window changes). A change inside it is flat.
206
+ */
207
+ noise?: number;
208
+ }
209
+ /** Changes smaller than this are noise. */
210
+ export declare const FLAT_WITHIN = 0.05;
211
+ export declare function outcomeOf(metric: MetricName, change: number, noise?: number): MetricChange["outcome"];
212
+ /**
213
+ * A verdict from outcomes, for a release whose ranks did not move (or that has no
214
+ * targets): won when the listing outcomes got better, lost when worse.
215
+ */
216
+ export declare function outcomeVerdict(changes: MetricChange[]): {
217
+ verdict: "won" | "lost" | "mixed" | "flat";
218
+ by: MetricName[];
219
+ } | null;
220
+ /** Every metric present in both windows, with a usable before value. */
221
+ export declare function compareWindows(before: MetricWindow, after: MetricWindow, noise?: Partial<Record<MetricName, number>>): MetricChange[];
222
+ /** Past windows used to learn how much each metric moves on its own. */
223
+ export declare const NOISE_WINDOWS = 4;
224
+ /**
225
+ * Twice the spread of window-to-window changes over consecutive past windows, per metric:
226
+ * roughly the range a metric moves in with nothing changed. Needs three or more changes.
227
+ */
228
+ export declare function noiseBands(windows: MetricWindow[]): Partial<Record<MetricName, number>>;
229
+ /** One Meta insights row (Graph API shape: numbers come as strings). */
230
+ export interface MetaInsightRow {
231
+ spend?: string | number;
232
+ actions?: Array<{
233
+ action_type: string;
234
+ value: string | number;
235
+ }>;
236
+ }
237
+ /** Spend and installs of the given Meta campaigns' insights rows, per day. */
238
+ export declare function summarizeMeta(rows: MetaInsightRow[], days: number): MetricWindow["values"];
239
+ /** One TikTok integrated report row. */
240
+ export interface TikTokReportRow {
241
+ dimensions?: {
242
+ campaign_id?: string;
243
+ };
244
+ metrics?: {
245
+ spend?: string | number;
246
+ conversion?: string | number;
247
+ };
248
+ }
249
+ /** Spend and conversions of the given TikTok campaigns, per day. Rows for other campaigns are left out. */
250
+ export declare function summarizeTikTok(rows: TikTokReportRow[], campaigns: string[], days: number): MetricWindow["values"];
251
+ /**
252
+ * Several sources as one: each window merges the values every source has.
253
+ * A source that fails or has no data is skipped, so one broken connection
254
+ * does not hide the others.
255
+ */
256
+ export declare function combineSources(sources: MetricSource[]): MetricSource;
257
+ export interface MetricsCheck {
258
+ source: string;
259
+ before: MetricWindow;
260
+ after: MetricWindow;
261
+ changes: MetricChange[];
262
+ }
263
+ /** Compare the `days` before a moment (a push, an experiment start) with the `days` after it. */
264
+ export declare function measureAround(source: MetricSource, store: StoreId, appId: string, at: string, days: number): Promise<MetricsCheck | null>;
265
+ /** One metric's average over the `days` before a moment, for an experiment baseline. */
266
+ export declare function metricBefore(source: MetricSource, store: StoreId, appId: string, metric: MetricName, at: string, days: number): Promise<number | undefined>;
267
+ /** One metric's average over the `days` from a moment on, for an experiment result. */
268
+ export declare function metricAfter(source: MetricSource, store: StoreId, appId: string, metric: MetricName, at: string, days: number): Promise<number | undefined>;
269
+ /** "conversion 3.1% → 3.6% (+16%)", "revenue/day $41.20 → $47.90 (+16%)" */
270
+ export declare function formatChange(c: MetricChange, currency?: string): string;