@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.
Files changed (111) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +636 -2
  4. package/agents/appeeky-ads.md +50 -0
  5. package/agents/appeeky-aso.md +58 -0
  6. package/agents/appeeky-releases.md +47 -0
  7. package/agents/appeeky-reviews.md +53 -0
  8. package/agents/appeeky-social.md +54 -0
  9. package/agents/appeeky-ugc.md +56 -0
  10. package/agents/cofounder.md +36 -0
  11. package/dist/ads.d.ts +42 -0
  12. package/dist/ads.js +71 -0
  13. package/dist/agents.d.ts +47 -0
  14. package/dist/agents.js +88 -0
  15. package/dist/appeeky-api.d.ts +62 -0
  16. package/dist/appeeky-api.js +240 -0
  17. package/dist/attribution-setup.d.ts +28 -0
  18. package/dist/attribution-setup.js +65 -0
  19. package/dist/attribution.d.ts +121 -0
  20. package/dist/attribution.js +170 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +4 -0
  23. package/dist/category.d.ts +71 -0
  24. package/dist/category.js +198 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +1960 -0
  27. package/dist/competitors.d.ts +74 -0
  28. package/dist/competitors.js +143 -0
  29. package/dist/control.d.ts +21 -0
  30. package/dist/control.js +53 -0
  31. package/dist/core.d.ts +15 -0
  32. package/dist/core.js +15 -0
  33. package/dist/coverage.d.ts +46 -0
  34. package/dist/coverage.js +115 -0
  35. package/dist/data.d.ts +48 -0
  36. package/dist/data.js +298 -0
  37. package/dist/dataforseo.d.ts +139 -0
  38. package/dist/dataforseo.js +354 -0
  39. package/dist/diff.d.ts +11 -0
  40. package/dist/diff.js +33 -0
  41. package/dist/experiments.d.ts +115 -0
  42. package/dist/experiments.js +85 -0
  43. package/dist/files.d.ts +27 -0
  44. package/dist/files.js +129 -0
  45. package/dist/history.d.ts +43 -0
  46. package/dist/history.js +55 -0
  47. package/dist/index.d.ts +24 -0
  48. package/dist/index.js +24 -0
  49. package/dist/limits.d.ts +15 -0
  50. package/dist/limits.js +30 -0
  51. package/dist/lint.d.ts +16 -0
  52. package/dist/lint.js +159 -0
  53. package/dist/loops.d.ts +37 -0
  54. package/dist/loops.js +197 -0
  55. package/dist/measure.d.ts +40 -0
  56. package/dist/measure.js +159 -0
  57. package/dist/metrics.d.ts +290 -0
  58. package/dist/metrics.js +254 -0
  59. package/dist/output.d.ts +21 -0
  60. package/dist/output.js +79 -0
  61. package/dist/project.d.ts +19 -0
  62. package/dist/project.js +35 -0
  63. package/dist/report.d.ts +10 -0
  64. package/dist/report.js +113 -0
  65. package/dist/reviews.d.ts +130 -0
  66. package/dist/reviews.js +287 -0
  67. package/dist/schema.d.ts +612 -0
  68. package/dist/schema.js +253 -0
  69. package/dist/screenshots.d.ts +23 -0
  70. package/dist/screenshots.js +116 -0
  71. package/dist/search-ads.d.ts +137 -0
  72. package/dist/search-ads.js +354 -0
  73. package/dist/seo.d.ts +87 -0
  74. package/dist/seo.js +391 -0
  75. package/dist/setup.d.ts +45 -0
  76. package/dist/setup.js +259 -0
  77. package/dist/social.d.ts +129 -0
  78. package/dist/social.js +189 -0
  79. package/dist/storefronts.d.ts +15 -0
  80. package/dist/storefronts.js +89 -0
  81. package/dist/stores/app-store.d.ts +33 -0
  82. package/dist/stores/app-store.js +159 -0
  83. package/dist/stores/google-play.d.ts +13 -0
  84. package/dist/stores/google-play.js +61 -0
  85. package/dist/stores/types.d.ts +34 -0
  86. package/dist/stores/types.js +2 -0
  87. package/dist/suggest.d.ts +73 -0
  88. package/dist/suggest.js +135 -0
  89. package/dist/text.d.ts +18 -0
  90. package/dist/text.js +55 -0
  91. package/dist/transport.d.ts +99 -0
  92. package/dist/transport.js +170 -0
  93. package/dist/ugc-core.js +1 -0
  94. package/dist/ugc-local.d.ts +116 -0
  95. package/dist/ugc-local.js +174 -0
  96. package/dist/ugc.d.ts +149 -0
  97. package/dist/ugc.js +111 -0
  98. package/dist/verify.d.ts +70 -0
  99. package/dist/verify.js +134 -0
  100. package/dist/visibility-core.js +96 -0
  101. package/dist/visibility-local.d.ts +159 -0
  102. package/dist/visibility-local.js +246 -0
  103. package/dist/visibility.d.ts +152 -0
  104. package/dist/visibility.js +239 -0
  105. package/package.json +80 -4
  106. package/skills/appeeky-spec/SKILL.md +142 -0
  107. package/skills/appeeky-spec-experiments/SKILL.md +93 -0
  108. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  109. package/skills/appeeky-spec-review/SKILL.md +75 -0
  110. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  111. package/skills/appeeky-spec-visibility/SKILL.md +106 -0
@@ -0,0 +1,290 @@
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 search_clicks: {
135
+ readonly label: "Google search clicks/day";
136
+ readonly kind: "count";
137
+ readonly better: "up";
138
+ };
139
+ readonly search_impressions: {
140
+ readonly label: "Google search impressions/day";
141
+ readonly kind: "count";
142
+ readonly better: "up";
143
+ };
144
+ readonly web_rank: {
145
+ readonly label: "average Google position of the site";
146
+ readonly kind: "count";
147
+ readonly better: "down";
148
+ };
149
+ readonly ai_overview_citations: {
150
+ readonly label: "Google AI Overviews that cite the site";
151
+ readonly kind: "rate";
152
+ readonly better: "up";
153
+ };
154
+ readonly category_rank: {
155
+ readonly label: "category rank";
156
+ readonly kind: "count";
157
+ readonly better: "down";
158
+ };
159
+ };
160
+ export type MetricName = keyof typeof METRICS;
161
+ export declare function isMetricName(name: string): name is MetricName;
162
+ /** Metric values over a date range: counts and money per day, rates over the whole range. */
163
+ export interface MetricWindow {
164
+ from: string;
165
+ /** Exclusive end. */
166
+ to: string;
167
+ days: number;
168
+ values: Partial<Record<MetricName, number>>;
169
+ currency?: string;
170
+ }
171
+ /** Where outcome metrics come from. */
172
+ export interface MetricSource {
173
+ readonly label: string;
174
+ /** Metrics between two ISO dates (inclusive start, exclusive end); null when there is no data. */
175
+ window(store: StoreId, appId: string, from: string, to: string): Promise<MetricWindow | null>;
176
+ }
177
+ /** The part of the Appeeky API response this module reads. */
178
+ export interface AppMetricsResponse {
179
+ store: {
180
+ days: Array<{
181
+ date: string;
182
+ pageViews: number | null;
183
+ downloads: number | null;
184
+ proceeds: number | null;
185
+ trials: number | null;
186
+ }>;
187
+ } | null;
188
+ ads: {
189
+ currency: string | null;
190
+ days: Array<{
191
+ date: string;
192
+ spend: number;
193
+ installs: number;
194
+ }>;
195
+ } | null;
196
+ revenuecat: {
197
+ scope: "app" | "project";
198
+ currency: string | null;
199
+ days: Array<{
200
+ date: string;
201
+ revenue: number | null;
202
+ trials: number | null;
203
+ trialConversion: number | null;
204
+ }>;
205
+ } | null;
206
+ }
207
+ /** Below this many product page views in a window, a conversion rate is noise. */
208
+ export declare const MIN_PAGE_VIEWS = 50;
209
+ /**
210
+ * Sum one API response into a window. RevenueCat numbers are only used when
211
+ * the RevenueCat project holds just this app; project-wide numbers would
212
+ * credit a listing change with another app's revenue.
213
+ */
214
+ export declare function summarizeMetrics(data: AppMetricsResponse, from: string, to: string): MetricWindow;
215
+ export interface MetricChange {
216
+ metric: MetricName;
217
+ before: number;
218
+ after: number;
219
+ /** Relative change, e.g. 0.12 = +12%. */
220
+ change: number;
221
+ /** Whether the move is good for the app, given which direction is better. */
222
+ outcome: "better" | "worse" | "flat";
223
+ /**
224
+ * How much this metric normally moves between two windows of the same length before the
225
+ * change (twice the spread of past window-to-window changes). A change inside it is flat.
226
+ */
227
+ noise?: number;
228
+ }
229
+ /** Changes smaller than this are noise. */
230
+ export declare const FLAT_WITHIN = 0.05;
231
+ export declare function outcomeOf(metric: MetricName, change: number, noise?: number): MetricChange["outcome"];
232
+ /**
233
+ * A verdict from outcomes, for a release whose ranks did not move (or that has no
234
+ * targets): won when the listing outcomes got better, lost when worse.
235
+ */
236
+ export declare function outcomeVerdict(changes: MetricChange[]): {
237
+ verdict: "won" | "lost" | "mixed" | "flat";
238
+ by: MetricName[];
239
+ } | null;
240
+ /** Every metric present in both windows, with a usable before value. */
241
+ export declare function compareWindows(before: MetricWindow, after: MetricWindow, noise?: Partial<Record<MetricName, number>>): MetricChange[];
242
+ /** Past windows used to learn how much each metric moves on its own. */
243
+ export declare const NOISE_WINDOWS = 4;
244
+ /**
245
+ * Twice the spread of window-to-window changes over consecutive past windows, per metric:
246
+ * roughly the range a metric moves in with nothing changed. Needs three or more changes.
247
+ */
248
+ export declare function noiseBands(windows: MetricWindow[]): Partial<Record<MetricName, number>>;
249
+ /** One Meta insights row (Graph API shape: numbers come as strings). */
250
+ export interface MetaInsightRow {
251
+ spend?: string | number;
252
+ actions?: Array<{
253
+ action_type: string;
254
+ value: string | number;
255
+ }>;
256
+ }
257
+ /** Spend and installs of the given Meta campaigns' insights rows, per day. */
258
+ export declare function summarizeMeta(rows: MetaInsightRow[], days: number): MetricWindow["values"];
259
+ /** One TikTok integrated report row. */
260
+ export interface TikTokReportRow {
261
+ dimensions?: {
262
+ campaign_id?: string;
263
+ };
264
+ metrics?: {
265
+ spend?: string | number;
266
+ conversion?: string | number;
267
+ };
268
+ }
269
+ /** Spend and conversions of the given TikTok campaigns, per day. Rows for other campaigns are left out. */
270
+ export declare function summarizeTikTok(rows: TikTokReportRow[], campaigns: string[], days: number): MetricWindow["values"];
271
+ /**
272
+ * Several sources as one: each window merges the values every source has.
273
+ * A source that fails or has no data is skipped, so one broken connection
274
+ * does not hide the others.
275
+ */
276
+ export declare function combineSources(sources: MetricSource[]): MetricSource;
277
+ export interface MetricsCheck {
278
+ source: string;
279
+ before: MetricWindow;
280
+ after: MetricWindow;
281
+ changes: MetricChange[];
282
+ }
283
+ /** Compare the `days` before a moment (a push, an experiment start) with the `days` after it. */
284
+ export declare function measureAround(source: MetricSource, store: StoreId, appId: string, at: string, days: number): Promise<MetricsCheck | null>;
285
+ /** One metric's average over the `days` before a moment, for an experiment baseline. */
286
+ export declare function metricBefore(source: MetricSource, store: StoreId, appId: string, metric: MetricName, at: string, days: number): Promise<number | undefined>;
287
+ /** One metric's average over the `days` from a moment on, for an experiment result. */
288
+ export declare function metricAfter(source: MetricSource, store: StoreId, appId: string, metric: MetricName, at: string, days: number): Promise<number | undefined>;
289
+ /** "conversion 3.1% → 3.6% (+16%)", "revenue/day $41.20 → $47.90 (+16%)" */
290
+ export declare function formatChange(c: MetricChange, currency?: string): string;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Business outcomes of a change, beyond search ranks: conversion, downloads,
3
+ * revenue, trials and paid installs. They come from the store analytics,
4
+ * Apple Search Ads and RevenueCat data a user has connected in Appeeky
5
+ * (`GET /v1/user/apps/:appId/metrics`), so they need APPEEKY_API_KEY.
6
+ */
7
+ export const METRICS = {
8
+ conversion: { label: "conversion", kind: "rate", better: "up" },
9
+ page_views: { label: "page views/day", kind: "count", better: "up" },
10
+ downloads: { label: "downloads/day", kind: "count", better: "up" },
11
+ proceeds: { label: "proceeds/day", kind: "money", better: "up" },
12
+ trials: { label: "trials/day", kind: "count", better: "up" },
13
+ revenue: { label: "revenue/day", kind: "money", better: "up" },
14
+ rc_trials: { label: "RevenueCat trials/day", kind: "count", better: "up" },
15
+ trial_conversion: { label: "trial conversion", kind: "rate", better: "up" },
16
+ ad_installs: { label: "ad installs/day", kind: "count", better: "up" },
17
+ cpa: { label: "cost per ad install", kind: "money", better: "down" },
18
+ rating: { label: "average rating", kind: "count", better: "up" },
19
+ organic_installs: { label: "organic installs/day", kind: "count", better: "up" },
20
+ organic_revenue: { label: "organic revenue/day", kind: "money", better: "up" },
21
+ organic_paid: { label: "organic installs that paid", kind: "rate", better: "up" },
22
+ link_clicks: { label: "clicks on the experiment's link", kind: "count", better: "up" },
23
+ link_installs: { label: "installs from the experiment's link", kind: "count", better: "up" },
24
+ link_revenue: { label: "revenue from the experiment's link", kind: "money", better: "up" },
25
+ low_ratings: { label: "1–2 star reviews", kind: "rate", better: "down" },
26
+ meta_spend: { label: "Meta spend/day", kind: "money", better: "down" },
27
+ meta_installs: { label: "Meta installs/day", kind: "count", better: "up" },
28
+ meta_cpi: { label: "Meta cost per install", kind: "money", better: "down" },
29
+ tiktok_spend: { label: "TikTok spend/day", kind: "money", better: "down" },
30
+ tiktok_conversions: { label: "TikTok conversions/day", kind: "count", better: "up" },
31
+ tiktok_cpa: { label: "TikTok cost per conversion", kind: "money", better: "down" },
32
+ ai_visibility: { label: "AI answers that name the app", kind: "rate", better: "up" },
33
+ search_clicks: { label: "Google search clicks/day", kind: "count", better: "up" },
34
+ search_impressions: { label: "Google search impressions/day", kind: "count", better: "up" },
35
+ web_rank: { label: "average Google position of the site", kind: "count", better: "down" },
36
+ ai_overview_citations: { label: "Google AI Overviews that cite the site", kind: "rate", better: "up" },
37
+ category_rank: { label: "category rank", kind: "count", better: "down" },
38
+ };
39
+ export function isMetricName(name) {
40
+ return name in METRICS;
41
+ }
42
+ /** Below this many product page views in a window, a conversion rate is noise. */
43
+ export const MIN_PAGE_VIEWS = 50;
44
+ const sum = (xs) => xs.reduce((n, x) => n + (x ?? 0), 0);
45
+ const known = (xs) => xs.filter((x) => x !== null && x !== undefined);
46
+ /**
47
+ * Sum one API response into a window. RevenueCat numbers are only used when
48
+ * the RevenueCat project holds just this app; project-wide numbers would
49
+ * credit a listing change with another app's revenue.
50
+ */
51
+ export function summarizeMetrics(data, from, to) {
52
+ const values = {};
53
+ const store = data.store?.days ?? [];
54
+ const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
55
+ if (store.length > 0) {
56
+ const pageViews = sum(store.map((d) => d.pageViews));
57
+ const downloads = sum(store.map((d) => d.downloads));
58
+ if (known(store.map((d) => d.pageViews)).length)
59
+ values.page_views = pageViews / days;
60
+ if (known(store.map((d) => d.downloads)).length)
61
+ values.downloads = downloads / days;
62
+ if (pageViews >= MIN_PAGE_VIEWS)
63
+ values.conversion = downloads / pageViews;
64
+ if (known(store.map((d) => d.proceeds)).length)
65
+ values.proceeds = sum(store.map((d) => d.proceeds)) / days;
66
+ if (known(store.map((d) => d.trials)).length)
67
+ values.trials = sum(store.map((d) => d.trials)) / days;
68
+ }
69
+ const ads = data.ads?.days ?? [];
70
+ if (ads.length > 0) {
71
+ const installs = sum(ads.map((d) => d.installs));
72
+ values.ad_installs = installs / days;
73
+ if (installs > 0)
74
+ values.cpa = sum(ads.map((d) => d.spend)) / installs;
75
+ }
76
+ const rc = data.revenuecat?.scope === "app" ? data.revenuecat.days : [];
77
+ if (rc.length > 0) {
78
+ if (known(rc.map((d) => d.revenue)).length)
79
+ values.revenue = sum(rc.map((d) => d.revenue)) / days;
80
+ if (known(rc.map((d) => d.trials)).length)
81
+ values.rc_trials = sum(rc.map((d) => d.trials)) / days;
82
+ const tc = known(rc.map((d) => d.trialConversion));
83
+ if (tc.length)
84
+ values.trial_conversion = tc.reduce((a, b) => a + b, 0) / tc.length;
85
+ }
86
+ const currency = data.revenuecat?.currency ?? data.ads?.currency ?? undefined;
87
+ return { from, to, days, values, ...(currency ? { currency } : {}) };
88
+ }
89
+ /** Changes smaller than this are noise. */
90
+ export const FLAT_WITHIN = 0.05;
91
+ export function outcomeOf(metric, change, noise = 0) {
92
+ if (Math.abs(change) < Math.max(FLAT_WITHIN, noise))
93
+ return "flat";
94
+ const up = change > 0;
95
+ return METRICS[metric].better === "up" ? (up ? "better" : "worse") : up ? "worse" : "better";
96
+ }
97
+ /**
98
+ * What a listing change is for: more people finding the app and paying. Organic numbers
99
+ * (from attribution) come first; without them, store conversion, then downloads, then
100
+ * the category rank (public, so it is there even without connected analytics).
101
+ */
102
+ const LISTING_OUTCOMES = [["organic_installs", "organic_revenue"], ["conversion"], ["downloads"], ["category_rank"]];
103
+ /**
104
+ * A verdict from outcomes, for a release whose ranks did not move (or that has no
105
+ * targets): won when the listing outcomes got better, lost when worse.
106
+ */
107
+ export function outcomeVerdict(changes) {
108
+ for (const group of LISTING_OUTCOMES) {
109
+ const found = changes.filter((c) => group.includes(c.metric));
110
+ if (found.length === 0)
111
+ continue;
112
+ const better = found.filter((c) => c.outcome === "better").length;
113
+ const worse = found.filter((c) => c.outcome === "worse").length;
114
+ const verdict = better && !worse ? "won" : worse && !better ? "lost" : better && worse ? "mixed" : "flat";
115
+ return { verdict, by: found.map((c) => c.metric) };
116
+ }
117
+ return null;
118
+ }
119
+ /** Every metric present in both windows, with a usable before value. */
120
+ export function compareWindows(before, after, noise = {}) {
121
+ const out = [];
122
+ for (const metric of Object.keys(METRICS)) {
123
+ const b = before.values[metric];
124
+ const a = after.values[metric];
125
+ if (b === undefined || a === undefined || b <= 0)
126
+ continue;
127
+ const change = a / b - 1;
128
+ const band = noise[metric];
129
+ out.push({ metric, before: b, after: a, change, outcome: outcomeOf(metric, change, band), ...(band !== undefined ? { noise: band } : {}) });
130
+ }
131
+ return out;
132
+ }
133
+ /** Past windows used to learn how much each metric moves on its own. */
134
+ export const NOISE_WINDOWS = 4;
135
+ /**
136
+ * Twice the spread of window-to-window changes over consecutive past windows, per metric:
137
+ * roughly the range a metric moves in with nothing changed. Needs three or more changes.
138
+ */
139
+ export function noiseBands(windows) {
140
+ const out = {};
141
+ for (const metric of Object.keys(METRICS)) {
142
+ const changes = [];
143
+ for (let i = 1; i < windows.length; i++) {
144
+ const b = windows[i - 1].values[metric];
145
+ const a = windows[i].values[metric];
146
+ if (b !== undefined && a !== undefined && b > 0)
147
+ changes.push(a / b - 1);
148
+ }
149
+ if (changes.length < 3)
150
+ continue;
151
+ const mean = changes.reduce((s, x) => s + x, 0) / changes.length;
152
+ const sd = Math.sqrt(changes.reduce((s, x) => s + (x - mean) ** 2, 0) / (changes.length - 1));
153
+ out[metric] = Math.round(2 * sd * 1000) / 1000;
154
+ }
155
+ return out;
156
+ }
157
+ /** Meta counts an app install under one of these action types, depending on how the campaign is set up. */
158
+ const META_INSTALL_ACTIONS = ["mobile_app_install", "app_install", "omni_app_install"];
159
+ const num = (v) => {
160
+ const n = typeof v === "number" ? v : Number(v);
161
+ return Number.isFinite(n) ? n : 0;
162
+ };
163
+ /** Spend and installs of the given Meta campaigns' insights rows, per day. */
164
+ export function summarizeMeta(rows, days) {
165
+ if (rows.length === 0)
166
+ return {};
167
+ const spend = rows.reduce((n, r) => n + num(r.spend), 0);
168
+ const installs = rows.reduce((n, r) => {
169
+ const actions = r.actions ?? [];
170
+ // Take the first install type present so one install is not counted twice.
171
+ const type = META_INSTALL_ACTIONS.find((t) => actions.some((a) => a.action_type === t));
172
+ return n + (type ? num(actions.find((a) => a.action_type === type)?.value) : 0);
173
+ }, 0);
174
+ const values = { meta_spend: spend / days, meta_installs: installs / days };
175
+ if (installs > 0)
176
+ values.meta_cpi = spend / installs;
177
+ return values;
178
+ }
179
+ /** Spend and conversions of the given TikTok campaigns, per day. Rows for other campaigns are left out. */
180
+ export function summarizeTikTok(rows, campaigns, days) {
181
+ const mine = rows.filter((r) => r.dimensions?.campaign_id && campaigns.includes(String(r.dimensions.campaign_id)));
182
+ if (mine.length === 0)
183
+ return {};
184
+ const spend = mine.reduce((n, r) => n + num(r.metrics?.spend), 0);
185
+ const conversions = mine.reduce((n, r) => n + num(r.metrics?.conversion), 0);
186
+ const values = { tiktok_spend: spend / days, tiktok_conversions: conversions / days };
187
+ if (conversions > 0)
188
+ values.tiktok_cpa = spend / conversions;
189
+ return values;
190
+ }
191
+ /**
192
+ * Several sources as one: each window merges the values every source has.
193
+ * A source that fails or has no data is skipped, so one broken connection
194
+ * does not hide the others.
195
+ */
196
+ export function combineSources(sources) {
197
+ return {
198
+ label: sources.map((s) => s.label).join(", "),
199
+ async window(store, appId, from, to) {
200
+ const windows = await Promise.all(sources.map((s) => s.window(store, appId, from, to).catch(() => null)));
201
+ const found = windows.filter((w) => w !== null);
202
+ if (found.length === 0)
203
+ return null;
204
+ const currency = found.find((w) => w.currency)?.currency;
205
+ return {
206
+ from,
207
+ to,
208
+ days: found[0].days,
209
+ values: Object.assign({}, ...found.map((w) => w.values)),
210
+ ...(currency ? { currency } : {}),
211
+ };
212
+ },
213
+ };
214
+ }
215
+ const iso = (ms) => new Date(ms).toISOString().slice(0, 10);
216
+ /** Compare the `days` before a moment (a push, an experiment start) with the `days` after it. */
217
+ export async function measureAround(source, store, appId, at, days) {
218
+ const t = Date.parse(at);
219
+ const span = Math.max(1, days) * 86_400_000;
220
+ // The day of the change itself is mixed, so it is in neither window.
221
+ const before = await source.window(store, appId, iso(t - span), iso(t));
222
+ const after = await source.window(store, appId, iso(t + 86_400_000), iso(t + 86_400_000 + span));
223
+ if (!before || !after)
224
+ return null;
225
+ // The windows before `before`, oldest first, to learn each metric's usual move.
226
+ const past = [];
227
+ for (let k = NOISE_WINDOWS; k >= 1; k--) {
228
+ const w = await source.window(store, appId, iso(t - (k + 1) * span), iso(t - k * span)).catch(() => null);
229
+ if (w)
230
+ past.push(w);
231
+ }
232
+ const changes = compareWindows(before, after, noiseBands([...past, before]));
233
+ return changes.length > 0 ? { source: source.label, before, after, changes } : null;
234
+ }
235
+ /** One metric's average over the `days` before a moment, for an experiment baseline. */
236
+ export async function metricBefore(source, store, appId, metric, at, days) {
237
+ const t = Date.parse(at);
238
+ const window = await source.window(store, appId, iso(t - Math.max(1, days) * 86_400_000), iso(t));
239
+ return window?.values[metric];
240
+ }
241
+ /** One metric's average over the `days` from a moment on, for an experiment result. */
242
+ export async function metricAfter(source, store, appId, metric, at, days) {
243
+ const t = Date.parse(at);
244
+ const window = await source.window(store, appId, iso(t), iso(t + Math.max(1, days) * 86_400_000));
245
+ return window?.values[metric];
246
+ }
247
+ /** "conversion 3.1% → 3.6% (+16%)", "revenue/day $41.20 → $47.90 (+16%)" */
248
+ export function formatChange(c, currency) {
249
+ const { kind, label } = METRICS[c.metric];
250
+ const fmt = (v) => kind === "rate" ? `${(v * 100).toFixed(1)}%` : kind === "money" ? `${currency && currency !== "USD" ? `${currency} ` : "$"}${v.toFixed(2)}` : v >= 10 ? v.toFixed(0) : v.toFixed(1);
251
+ const pct = `${c.change >= 0 ? "+" : ""}${(c.change * 100).toFixed(0)}%`;
252
+ return `${label} ${fmt(c.before)} → ${fmt(c.after)} (${pct})`;
253
+ }
254
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1,21 @@
1
+ import type { StorefrontCoverage } from "./coverage.js";
2
+ import type { Issue } from "./lint.js";
3
+ import type { FieldChange } from "./stores/types.js";
4
+ export declare const c: {
5
+ bold: (s: string) => string;
6
+ dim: (s: string) => string;
7
+ red: (s: string) => string;
8
+ green: (s: string) => string;
9
+ yellow: (s: string) => string;
10
+ cyan: (s: string) => string;
11
+ };
12
+ export declare function printJson(value: unknown): void;
13
+ declare const STORE_LABEL: {
14
+ readonly "app-store": "App Store";
15
+ readonly "google-play": "Google Play";
16
+ };
17
+ export declare const storeLabel: (s: keyof typeof STORE_LABEL) => "App Store" | "Google Play";
18
+ export declare function formatIssues(issues: Issue[]): string;
19
+ export declare function formatChanges(changes: FieldChange[]): string;
20
+ export declare function formatCoverage(reports: StorefrontCoverage[]): string;
21
+ export {};
package/dist/output.js ADDED
@@ -0,0 +1,79 @@
1
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
2
+ const wrap = (code) => (s) => (useColor ? `\u001b[${code}m${s}\u001b[0m` : s);
3
+ export const c = {
4
+ bold: wrap(1),
5
+ dim: wrap(2),
6
+ red: wrap(31),
7
+ green: wrap(32),
8
+ yellow: wrap(33),
9
+ cyan: wrap(36),
10
+ };
11
+ export function printJson(value) {
12
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
13
+ }
14
+ const STORE_LABEL = { "app-store": "App Store", "google-play": "Google Play" };
15
+ export const storeLabel = (s) => STORE_LABEL[s];
16
+ export function formatIssues(issues) {
17
+ if (issues.length === 0)
18
+ return c.green("No issues.");
19
+ const icon = { error: c.red("error"), warning: c.yellow("warn "), info: c.cyan("info ") };
20
+ return issues
21
+ .map((i) => `${icon[i.severity]} ${c.dim(`${storeLabel(i.store)} ${i.locale}${i.field ? ` ${i.field}` : ""}`)} ${i.message} ${c.dim(`(${i.rule})`)}`)
22
+ .join("\n");
23
+ }
24
+ function preview(value, width = 70) {
25
+ if (value === undefined)
26
+ return c.dim("(empty)");
27
+ const flat = value.replace(/\n+/g, " ⏎ ");
28
+ return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat;
29
+ }
30
+ export function formatChanges(changes) {
31
+ if (changes.length === 0)
32
+ return c.green("In sync with the store.");
33
+ const lines = [];
34
+ let last = "";
35
+ for (const ch of changes) {
36
+ const head = `${storeLabel(ch.store)} ${ch.locale}${ch.newLocale ? c.yellow(" (new locale)") : ""}`;
37
+ if (head !== last) {
38
+ lines.push(c.bold(head));
39
+ last = head;
40
+ }
41
+ lines.push(` ${ch.field}`);
42
+ lines.push(` ${c.red("-")} ${preview(ch.before)}`);
43
+ lines.push(` ${c.green("+")} ${preview(ch.after)}`);
44
+ if (ch.blocked)
45
+ lines.push(` ${c.yellow("blocked:")} ${ch.blocked}`);
46
+ }
47
+ return lines.join("\n");
48
+ }
49
+ const LEVEL_LABEL = {
50
+ title: "in title",
51
+ subtitle: "name+subtitle",
52
+ short: "short desc",
53
+ full: "indexed",
54
+ split: "split locales",
55
+ partial: "partial",
56
+ none: "missing",
57
+ };
58
+ export function formatCoverage(reports) {
59
+ if (reports.length === 0)
60
+ return c.dim("No targets yet. Add keywords under `targets:` in appeeky.spec.yaml.");
61
+ const out = [];
62
+ for (const r of reports) {
63
+ const weighted = r.weightedPercent !== undefined ? `, ${r.weightedPercent}% by popularity` : "";
64
+ out.push(c.bold(`${storeLabel(r.store)} ${r.country.toUpperCase()}`) +
65
+ c.dim(` (${r.locales.join(", ") || "no listing"})`) +
66
+ ` ${r.covered}/${r.total} covered (${r.percent}%${weighted})`);
67
+ const rows = [...r.keywords].sort((a, b) => (b.popularity ?? -1) - (a.popularity ?? -1));
68
+ for (const k of rows) {
69
+ const ok = ["title", "subtitle", "short", "full"].includes(k.level);
70
+ const mark = ok ? c.green("✓") : k.level === "none" ? c.red("✗") : c.yellow("~");
71
+ const pop = k.popularity !== undefined ? c.dim(` pop ${String(k.popularity).padStart(3)} diff ${String(k.difficulty ?? "-").padStart(3)}`) : "";
72
+ const missing = k.missing.length > 0 ? c.dim(` missing: ${k.missing.join(", ")}`) : "";
73
+ out.push(` ${mark} ${k.keyword.padEnd(28)} ${LEVEL_LABEL[k.level].padEnd(14)}${pop}${missing}`);
74
+ }
75
+ out.push("");
76
+ }
77
+ return out.join("\n").trimEnd();
78
+ }
79
+ //# sourceMappingURL=output.js.map
@@ -0,0 +1,19 @@
1
+ import type { Project, StoreId } from "./schema.js";
2
+ import type { FieldChange, RemoteState, StoreAdapter } from "./stores/types.js";
3
+ import { type OpsTransport, type TransportOptions } from "./transport.js";
4
+ export interface StoreHandle {
5
+ store: StoreId;
6
+ adapter: StoreAdapter;
7
+ transport: OpsTransport;
8
+ }
9
+ /** Stores configured in the spec, optionally narrowed to one. */
10
+ export declare function configuredStores(project: Project, only?: StoreId): StoreId[];
11
+ export declare function storeHandle(project: Project, store: StoreId, overrides?: Partial<TransportOptions>, transportOverride?: OpsTransport): StoreHandle;
12
+ export interface StorePlan {
13
+ store: StoreId;
14
+ via: string;
15
+ state: RemoteState;
16
+ changes: FieldChange[];
17
+ }
18
+ /** Pull the store, diff it with the spec and mark what cannot be pushed yet. */
19
+ export declare function planStore(project: Project, handle: StoreHandle): Promise<StorePlan>;