adapty 0.10.0 → 0.12.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/README.md +32 -0
- package/dist/cli/base/adapty/flags.d.ts +65 -0
- package/dist/cli/base/adapty/flags.js +95 -0
- package/dist/cli/base/adapty/index.d.ts +1 -0
- package/dist/cli/base/adapty/index.js +1 -0
- package/dist/cli/base/attribution/flags.d.ts +3 -3
- package/dist/cli/base/attribution/flags.js +5 -20
- package/dist/cli/commands/analytics/catalog.d.ts +10 -0
- package/dist/cli/commands/analytics/catalog.js +52 -0
- package/dist/cli/commands/analytics/chart.d.ts +21 -0
- package/dist/cli/commands/analytics/chart.js +47 -0
- package/dist/cli/commands/analytics/cohort.d.ts +19 -0
- package/dist/cli/commands/analytics/cohort.js +40 -0
- package/dist/cli/commands/analytics/conversion.d.ts +20 -0
- package/dist/cli/commands/analytics/conversion.js +44 -0
- package/dist/cli/commands/analytics/funnel.d.ts +16 -0
- package/dist/cli/commands/analytics/funnel.js +32 -0
- package/dist/cli/commands/analytics/ltv.d.ts +19 -0
- package/dist/cli/commands/analytics/ltv.js +40 -0
- package/dist/cli/commands/analytics/retention.d.ts +17 -0
- package/dist/cli/commands/analytics/retention.js +41 -0
- package/dist/cli/commands/analytics/values.d.ts +11 -0
- package/dist/cli/commands/analytics/values.js +33 -0
- package/dist/cli/commands/attribution/report/command.js +6 -11
- package/dist/cli/errors.js +8 -1
- package/dist/cli/input/filter.d.ts +11 -0
- package/dist/cli/input/filter.js +16 -0
- package/dist/cli/input/period.d.ts +10 -0
- package/dist/cli/input/period.js +23 -0
- package/dist/cli/views/analytics/csv.d.ts +3 -0
- package/dist/cli/views/analytics/csv.js +16 -0
- package/dist/cli/views/analytics/human.d.ts +41 -0
- package/dist/cli/views/analytics/human.js +134 -0
- package/dist/cli/views/analytics/segments.d.ts +68 -0
- package/dist/cli/views/analytics/segments.js +185 -0
- package/dist/commands/analytics/catalog.d.ts +1 -0
- package/dist/commands/analytics/catalog.js +2 -0
- package/dist/commands/analytics/chart.d.ts +1 -0
- package/dist/commands/analytics/chart.js +2 -0
- package/dist/commands/analytics/cohort.d.ts +1 -0
- package/dist/commands/analytics/cohort.js +2 -0
- package/dist/commands/analytics/conversion.d.ts +1 -0
- package/dist/commands/analytics/conversion.js +2 -0
- package/dist/commands/analytics/funnel.d.ts +1 -0
- package/dist/commands/analytics/funnel.js +2 -0
- package/dist/commands/analytics/ltv.d.ts +1 -0
- package/dist/commands/analytics/ltv.js +2 -0
- package/dist/commands/analytics/retention.d.ts +1 -0
- package/dist/commands/analytics/retention.js +2 -0
- package/dist/commands/analytics/values.d.ts +1 -0
- package/dist/commands/analytics/values.js +2 -0
- package/dist/commands/placements/create.js +4 -0
- package/dist/commands/placements/update.js +4 -0
- package/dist/lib/api-schemas.d.ts +24 -1
- package/dist/lib/placement-audiences.d.ts +3 -1
- package/dist/lib/placement-audiences.js +16 -9
- package/dist/sdk/adapty/analytics/catalog.d.ts +51 -0
- package/dist/sdk/adapty/analytics/catalog.js +5 -0
- package/dist/sdk/adapty/analytics/chart.d.ts +41 -0
- package/dist/sdk/adapty/analytics/chart.js +6 -0
- package/dist/sdk/adapty/analytics/cohort.d.ts +79 -0
- package/dist/sdk/adapty/analytics/cohort.js +4 -0
- package/dist/sdk/adapty/analytics/conversion.d.ts +55 -0
- package/dist/sdk/adapty/analytics/conversion.js +7 -0
- package/dist/sdk/adapty/analytics/funnel.d.ts +41 -0
- package/dist/sdk/adapty/analytics/funnel.js +3 -0
- package/dist/sdk/adapty/analytics/index.d.ts +24 -0
- package/dist/sdk/adapty/analytics/index.js +13 -0
- package/dist/sdk/adapty/analytics/ltv.d.ts +27 -0
- package/dist/sdk/adapty/analytics/ltv.js +4 -0
- package/dist/sdk/adapty/analytics/model.d.ts +53 -0
- package/dist/sdk/adapty/analytics/model.js +12 -0
- package/dist/sdk/adapty/analytics/query.d.ts +48 -0
- package/dist/sdk/adapty/analytics/query.js +52 -0
- package/dist/sdk/adapty/analytics/renewal-periods.d.ts +23 -0
- package/dist/sdk/adapty/analytics/renewal-periods.js +27 -0
- package/dist/sdk/adapty/analytics/resource.d.ts +29 -0
- package/dist/sdk/adapty/analytics/resource.js +72 -0
- package/dist/sdk/adapty/analytics/retention.d.ts +32 -0
- package/dist/sdk/adapty/analytics/retention.js +6 -0
- package/dist/sdk/adapty/analytics/values.d.ts +20 -0
- package/dist/sdk/adapty/analytics/values.js +1 -0
- package/dist/sdk/adapty/index.d.ts +4 -0
- package/dist/sdk/adapty/index.js +3 -0
- package/dist/sdk/attribution/index.d.ts +1 -1
- package/dist/sdk/attribution/index.js +1 -1
- package/dist/sdk/attribution/report.d.ts +0 -2
- package/dist/sdk/attribution/report.js +2 -26
- package/dist/sdk/core/dates.d.ts +15 -0
- package/dist/sdk/core/dates.js +34 -0
- package/dist/sdk/core/errors.d.ts +3 -0
- package/oclif.manifest.json +898 -134
- package/package.json +4 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { csvLine } from './csv.js';
|
|
2
|
+
/** A number the backend does not have is null, and printing it as 0 would be a lie. */
|
|
3
|
+
export const NO_VALUE = '—';
|
|
4
|
+
export const NO_DATA = 'No data for this period.';
|
|
5
|
+
/** The fields that name a group; every other field of a segment is a number of that group. */
|
|
6
|
+
const IDENTITY = ['key', 'title', 'is_total'];
|
|
7
|
+
/** The segment fields every operation has: the CSV header of an answer without segments. */
|
|
8
|
+
const BASE_COLUMNS = [...IDENTITY, 'value'];
|
|
9
|
+
/** An empty text (the key of a "No Data" group, for example) is shown as `""`, so it never reads as a missing value. */
|
|
10
|
+
export const formatValue = (value) => {
|
|
11
|
+
if (value === null || value === undefined) {
|
|
12
|
+
return NO_VALUE;
|
|
13
|
+
}
|
|
14
|
+
return value === '' ? '""' : String(value);
|
|
15
|
+
};
|
|
16
|
+
/** Every leaf of a value under its dotted name. An empty list has no leaf; a null object is one null leaf. */
|
|
17
|
+
const leaves = (value, name) => {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.flatMap((item, index) => leaves(item, `${name}[${index}]`));
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === 'object' && value !== null) {
|
|
22
|
+
return Object.entries(value).flatMap(([key, item]) => leaves(item, name === '' ? key : `${name}.${key}`));
|
|
23
|
+
}
|
|
24
|
+
return [[name, value]];
|
|
25
|
+
};
|
|
26
|
+
/** The fields of an object, leaving out the ones the view shows in another way. */
|
|
27
|
+
const fieldsOf = (value, skip) => leaves(Object.fromEntries(Object.entries(value).filter(([key]) => !skip.includes(key))), '');
|
|
28
|
+
/** The API answers with the total first; the views hold to that whatever the order, and drop nothing. */
|
|
29
|
+
const totalFirst = (segments) => [...segments.filter(segment => segment.is_total), ...segments.filter(segment => !segment.is_total)];
|
|
30
|
+
/** A point is found by its `x`. */
|
|
31
|
+
export const pointId = (point) => String(point.x);
|
|
32
|
+
/**
|
|
33
|
+
* How the human view names a point: its `x`, and for a funnel step or a retention period also its title, the
|
|
34
|
+
* number once (`-2 Install`, `1 Paid`, `2 Period`).
|
|
35
|
+
*/
|
|
36
|
+
export const pointLabel = (point) => {
|
|
37
|
+
const { title } = point;
|
|
38
|
+
const id = pointId(point);
|
|
39
|
+
if (typeof title !== 'string') {
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
return title.startsWith(`${id} `) ? title : `${id} ${title}`;
|
|
43
|
+
};
|
|
44
|
+
/** The same for the CSV, `value` first: the title stays a field, since a step's title can differ between segments. */
|
|
45
|
+
const pointCells = (point) => {
|
|
46
|
+
const fields = fieldsOf(point, ['x']);
|
|
47
|
+
return [...fields.filter(([name]) => name === 'value'), ...fields.filter(([name]) => name !== 'value')];
|
|
48
|
+
};
|
|
49
|
+
const without = (value, names) => Object.fromEntries(Object.entries(value).filter(([name]) => !names.includes(name)));
|
|
50
|
+
/**
|
|
51
|
+
* Every field of the answer besides the segments, under its JSON path: `data.value`, `data.compare.value`,
|
|
52
|
+
* `meta.query.timezone`, `meta.query.filters.date[0]`, `meta.recommended_period.date_from`. The one source of the
|
|
53
|
+
* summary in both views.
|
|
54
|
+
*/
|
|
55
|
+
const answerFields = ({ data, meta }) => {
|
|
56
|
+
const { compare } = data;
|
|
57
|
+
return leaves({
|
|
58
|
+
data: {
|
|
59
|
+
...without(data, ['compare', 'segments']),
|
|
60
|
+
// A chart or a conversion always says whether it has a compared period: null is a value too
|
|
61
|
+
...(compare === undefined ? {} : { compare: compare === null ? null : without(compare, ['segments']) }),
|
|
62
|
+
},
|
|
63
|
+
meta,
|
|
64
|
+
}, '');
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* The summary of the asked period: every field of `data` besides the segments, every field of `meta.query` (the
|
|
68
|
+
* resolved query: the time zone, the filters, the defaults) and every other field of `meta` (a conversion's
|
|
69
|
+
* `recommended_period`), named as in their object. Of a compared period: the fields of `data.compare`.
|
|
70
|
+
*/
|
|
71
|
+
export const sectionsOf = (answer) => {
|
|
72
|
+
const fields = answerFields(answer);
|
|
73
|
+
const { compare, segments } = answer.data;
|
|
74
|
+
const main = fields.filter(([name]) => !name.startsWith('data.compare'))
|
|
75
|
+
.map(([name, value]) => [name.replace(/^(?:data|meta\.query|meta)\./, ''), value]);
|
|
76
|
+
const compared = fields.filter(([name]) => name.startsWith('data.compare.'))
|
|
77
|
+
.map(([name, value]) => [name.slice('data.compare.'.length), value]);
|
|
78
|
+
return [
|
|
79
|
+
{ period: 'main', segments: totalFirst(segments), summary: main },
|
|
80
|
+
...(compare === undefined || compare === null ? [] : [{ period: 'compare', segments: totalFirst(compare.segments), summary: compared }]),
|
|
81
|
+
];
|
|
82
|
+
};
|
|
83
|
+
export const line = ([name, value]) => `${name}: ${formatValue(value)}`;
|
|
84
|
+
/** Names in first-seen order, without a name that only stands for its nested fields elsewhere (a null `predict`). */
|
|
85
|
+
const columnsOf = (rows) => {
|
|
86
|
+
const names = [...new Set(rows.flatMap(fields => fields.map(([name]) => name)))];
|
|
87
|
+
return names.filter(name => !names.some(other => other.startsWith(`${name}.`) || other.startsWith(`${name}[`)));
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* The total is the segment with `is_total`; every other group is found by its raw key, so `null`, `""` and `"None"`
|
|
91
|
+
* are three groups, and none of them is the total. A key can repeat inside one period (a funnel key is the group
|
|
92
|
+
* title): such a key is the same group in both periods only by position, the first with the first, and so on. A key
|
|
93
|
+
* that is unique in both periods is matched by itself.
|
|
94
|
+
*/
|
|
95
|
+
const groupsOf = ({ data }) => {
|
|
96
|
+
const periods = [
|
|
97
|
+
['main', totalFirst(data.segments)],
|
|
98
|
+
['compare', totalFirst(data.compare?.segments ?? [])],
|
|
99
|
+
];
|
|
100
|
+
const keyOf = (segment) => JSON.stringify(segment.key);
|
|
101
|
+
const count = (segments, key) => segments
|
|
102
|
+
.filter(segment => !segment.is_total && keyOf(segment) === key).length;
|
|
103
|
+
const groups = new Map();
|
|
104
|
+
for (const [period, segments] of periods) {
|
|
105
|
+
const seen = new Map();
|
|
106
|
+
for (const segment of segments) {
|
|
107
|
+
const key = keyOf(segment);
|
|
108
|
+
const position = seen.get(key) ?? 0;
|
|
109
|
+
seen.set(key, position + 1);
|
|
110
|
+
const unique = periods.every(([, other]) => count(other, key) <= 1);
|
|
111
|
+
const own = unique ? `key:${key}` : `key:${key}#${position}`;
|
|
112
|
+
const id = segment.is_total ? 'total' : own;
|
|
113
|
+
groups.set(id, { ...groups.get(id), [period]: segment });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const total = groups.get('total');
|
|
117
|
+
const others = [...groups.entries()].filter(([id]) => id !== 'total').map(([, group]) => group);
|
|
118
|
+
return [...(total === undefined ? [] : [total]), ...others];
|
|
119
|
+
};
|
|
120
|
+
/** The columns of one period: the numbers of its segments, then each point's `value` and its other fields. */
|
|
121
|
+
const periodColumns = (segments, prefix, pick) => {
|
|
122
|
+
const own = columnsOf(segments.map(segment => fieldsOf(segment, [...IDENTITY, 'values'])))
|
|
123
|
+
.map(name => ({ header: `${prefix}${name}`, read: (group) => new Map(fieldsOf(pick(group) ?? {}, [])).get(name) }));
|
|
124
|
+
const points = new Map();
|
|
125
|
+
for (const point of segments.flatMap(segment => segment.values)) {
|
|
126
|
+
points.set(pointId(point), [...points.get(pointId(point)) ?? [], pointCells(point)]);
|
|
127
|
+
}
|
|
128
|
+
// Named by `x` alone, the same for every segment; a step's title is a cell of its own, `<x>.title`
|
|
129
|
+
const pointColumns = [...points.entries()].flatMap(([id, fields]) => columnsOf(fields).map(name => ({
|
|
130
|
+
header: `${prefix}${name === 'value' ? id : `${id}.${name}`}`,
|
|
131
|
+
read: (group) => {
|
|
132
|
+
const point = pick(group)?.values.find(item => pointId(item) === id);
|
|
133
|
+
return point === undefined ? undefined : new Map(pointCells(point)).get(name);
|
|
134
|
+
},
|
|
135
|
+
})));
|
|
136
|
+
return [...own, ...pointColumns];
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* The CSV view, made from the API answer: a header line, then one row per group, the total first (`is_total`
|
|
140
|
+
* true; its `key` cell is empty, as is the cell of a group whose raw key is null; a key `""` is written `""`).
|
|
141
|
+
* Columns: `key`, `title`, `is_total`; the numbers of the group in the asked period, then each point's `value`
|
|
142
|
+
* (named by its `x`) and its other fields as `<x>.<field>` (a funnel step's or retention period's title as
|
|
143
|
+
* `<x>.title`); the same for the compared period under `compare.`; then every other field of the answer under its
|
|
144
|
+
* JSON path (`data.value`, `data.compare.value`, `meta.query.timezone`, `meta.query.revenue_basis`, ...), the same
|
|
145
|
+
* on every row. A group of one period only has empty cells in the other. A null value is an empty cell. No groups:
|
|
146
|
+
* the header line alone.
|
|
147
|
+
*/
|
|
148
|
+
export const renderCsv = (answer) => {
|
|
149
|
+
const groups = groupsOf(answer);
|
|
150
|
+
const main = totalFirst(answer.data.segments);
|
|
151
|
+
const compare = totalFirst(answer.data.compare?.segments ?? []);
|
|
152
|
+
const summary = answerFields(answer);
|
|
153
|
+
const summaryNames = columnsOf([summary]);
|
|
154
|
+
const summaryCells = new Map(summary);
|
|
155
|
+
const identity = IDENTITY.map(name => ({
|
|
156
|
+
header: name,
|
|
157
|
+
read: group => new Map(fieldsOf(group.main ?? group.compare ?? {}, [])).get(name),
|
|
158
|
+
}));
|
|
159
|
+
const columns = [
|
|
160
|
+
...identity,
|
|
161
|
+
...(main.length === 0
|
|
162
|
+
? BASE_COLUMNS.slice(IDENTITY.length).map(name => ({ header: name, read: () => undefined }))
|
|
163
|
+
: periodColumns(main, '', group => group.main)),
|
|
164
|
+
...periodColumns(compare, 'compare.', group => group.compare),
|
|
165
|
+
...summaryNames.map(name => ({ header: name, read: () => summaryCells.get(name) })),
|
|
166
|
+
];
|
|
167
|
+
const rows = groups.map(group => columns.map(column => column.read(group)));
|
|
168
|
+
return [columns.map(column => column.header), ...rows].map(cells => csvLine(cells)).join('\n');
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* What a data command writes to stderr: with `--csv`, when the asked period has no segments, `No data for this
|
|
172
|
+
* period.` and the summary of each period, as the human view shows them (the CSV is then its header alone, or the
|
|
173
|
+
* groups of the compared period); else nothing, an empty text.
|
|
174
|
+
*/
|
|
175
|
+
export const csvNotice = (answer, csv) => {
|
|
176
|
+
if (!csv || answer.data.segments.length > 0) {
|
|
177
|
+
return '';
|
|
178
|
+
}
|
|
179
|
+
const sections = sectionsOf(answer).map(section => [
|
|
180
|
+
...(section.period === 'compare' ? ['Compared period'] : []),
|
|
181
|
+
...(section.segments.length === 0 ? [NO_DATA] : []),
|
|
182
|
+
...section.summary.map(field => line(field)),
|
|
183
|
+
].join('\n'));
|
|
184
|
+
return `${sections.join('\n\n')}\n`;
|
|
185
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/analytics/catalog.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/analytics/chart.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/analytics/cohort.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/analytics/conversion.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/analytics/funnel.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/analytics/ltv.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/analytics/retention.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/analytics/values.js';
|
|
@@ -18,6 +18,10 @@ export default class PlacementsCreate extends Command {
|
|
|
18
18
|
description: 'JSON array of audience entries. Every entry needs an explicit content_type. '
|
|
19
19
|
+ 'Paywall: {content_type:"paywall", segment_ids, paywall_id, priority}. '
|
|
20
20
|
+ 'Flow: {content_type:"flow", segment_ids, flow_id, priority}. '
|
|
21
|
+
+ 'A/B test (as `placements get` shows it): {content_type:"ab_test" | "flow_ab_test", ab_test_id, segment_ids, priority}; '
|
|
22
|
+
+ 'crossplacement entries ("cross_ab_test", "flow_cross_ab_test") are read-only, the API keeps the stored ones. '
|
|
23
|
+
+ 'Write the entries of `placements get` back unchanged to keep a running test: a write that removes or replaces '
|
|
24
|
+
+ 'an A/B test entry stops that test with no winner. '
|
|
21
25
|
+ 'A flow must be published first (flows publish) — attaching a draft flow returns 400.',
|
|
22
26
|
exactlyOne: ['paywall-id', 'audiences'],
|
|
23
27
|
}),
|
|
@@ -22,6 +22,10 @@ export default class PlacementsUpdate extends Command {
|
|
|
22
22
|
description: 'JSON array of audience entries. Every entry needs an explicit content_type. '
|
|
23
23
|
+ 'Paywall: {content_type:"paywall", segment_ids, paywall_id, priority}. '
|
|
24
24
|
+ 'Flow: {content_type:"flow", segment_ids, flow_id, priority}. '
|
|
25
|
+
+ 'A/B test (as `placements get` shows it): {content_type:"ab_test" | "flow_ab_test", ab_test_id, segment_ids, priority}; '
|
|
26
|
+
+ 'crossplacement entries ("cross_ab_test", "flow_cross_ab_test") are read-only, the API keeps the stored ones. '
|
|
27
|
+
+ 'Write the entries of `placements get` back unchanged to keep a running test: a write that removes or replaces '
|
|
28
|
+
+ 'an A/B test entry stops that test with no winner. '
|
|
25
29
|
+ 'A flow must be published first (flows publish) — attaching a draft flow returns 400.',
|
|
26
30
|
exactlyOne: ['paywall-id', 'audiences'],
|
|
27
31
|
}),
|
|
@@ -173,7 +173,30 @@ export type PlacementFlowAudienceEntryDTO = {
|
|
|
173
173
|
priority: number;
|
|
174
174
|
segment_ids?: string[];
|
|
175
175
|
};
|
|
176
|
-
|
|
176
|
+
/**
|
|
177
|
+
* An audience that runs a regular A/B test: the running content of the audience is the test.
|
|
178
|
+
* `ab_test_title` is read-only: the API ignores it on a write. Writing the same `ab_test_id` back
|
|
179
|
+
* on the same segments keeps the test running; any other id or segment set starts or stops a test.
|
|
180
|
+
*/
|
|
181
|
+
export type PlacementABTestAudienceEntryDTO = {
|
|
182
|
+
ab_test_id: string;
|
|
183
|
+
ab_test_title?: string;
|
|
184
|
+
content_type: 'ab_test' | 'flow_ab_test';
|
|
185
|
+
priority: number;
|
|
186
|
+
segment_ids?: string[];
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* An audience that shows a crossplacement A/B test (`ab_test_id` is the parent test). Read-only:
|
|
190
|
+
* the API ignores these entries on a write and keeps the stored ones.
|
|
191
|
+
*/
|
|
192
|
+
export type PlacementCrossABTestAudienceEntryDTO = {
|
|
193
|
+
ab_test_id: string;
|
|
194
|
+
ab_test_title?: string;
|
|
195
|
+
content_type: 'cross_ab_test' | 'flow_cross_ab_test';
|
|
196
|
+
priority: number;
|
|
197
|
+
segment_ids?: string[];
|
|
198
|
+
};
|
|
199
|
+
export type PlacementAudienceEntryDTO = PlacementABTestAudienceEntryDTO | PlacementCrossABTestAudienceEntryDTO | PlacementFlowAudienceEntryDTO | PlacementPaywallAudienceEntryDTO;
|
|
177
200
|
export type PlacementSummaryDTO = {
|
|
178
201
|
developer_id: string;
|
|
179
202
|
id: string;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Returns a human-readable problem string when `entry` is not a valid placement
|
|
3
3
|
* audience entry, or `null` when it is well-formed. Every entry must carry an
|
|
4
|
-
* explicit `content_type
|
|
4
|
+
* explicit `content_type`; there is no implicit paywall. A kind that this CLI does
|
|
5
|
+
* not know is refused with an "update the CLI" text, never with a text that invites
|
|
6
|
+
* deleting the entry: a write without a running A/B test entry stops that test.
|
|
5
7
|
*/
|
|
6
8
|
export declare function audienceEntryProblem(entry: unknown): null | string;
|
|
@@ -2,24 +2,31 @@
|
|
|
2
2
|
// The DTO union in api-schemas.ts requires content_type, but a JSON.parse gives
|
|
3
3
|
// back `unknown`, so the type alone cannot enforce it — this does, the same way
|
|
4
4
|
// for humans and agents (no prompting).
|
|
5
|
+
/** The A/B test audience kinds: regular and crossplacement, paywall and flow. Each needs `ab_test_id`. */
|
|
6
|
+
const A_B_TEST_KINDS = new Set(['ab_test', 'cross_ab_test', 'flow_ab_test', 'flow_cross_ab_test']);
|
|
5
7
|
/**
|
|
6
8
|
* Returns a human-readable problem string when `entry` is not a valid placement
|
|
7
9
|
* audience entry, or `null` when it is well-formed. Every entry must carry an
|
|
8
|
-
* explicit `content_type
|
|
10
|
+
* explicit `content_type`; there is no implicit paywall. A kind that this CLI does
|
|
11
|
+
* not know is refused with an "update the CLI" text, never with a text that invites
|
|
12
|
+
* deleting the entry: a write without a running A/B test entry stops that test.
|
|
9
13
|
*/
|
|
10
14
|
export function audienceEntryProblem(entry) {
|
|
11
15
|
if (typeof entry !== 'object' || entry === null) {
|
|
12
16
|
return 'each entry must be a JSON object';
|
|
13
17
|
}
|
|
14
|
-
const { content_type: contentType, flow_id: flowId, paywall_id: paywallId } = entry;
|
|
15
|
-
if (
|
|
16
|
-
return 'content_type is required
|
|
18
|
+
const { ab_test_id: abTestId, content_type: contentType, flow_id: flowId, paywall_id: paywallId, } = entry;
|
|
19
|
+
if (typeof contentType !== 'string') {
|
|
20
|
+
return 'content_type is required: "paywall", "flow", "ab_test", "flow_ab_test", "cross_ab_test" or "flow_cross_ab_test"';
|
|
17
21
|
}
|
|
18
|
-
if (contentType === 'paywall'
|
|
19
|
-
return 'a paywall entry requires paywall_id';
|
|
22
|
+
if (contentType === 'paywall') {
|
|
23
|
+
return typeof paywallId === 'string' ? null : 'a paywall entry requires paywall_id';
|
|
20
24
|
}
|
|
21
|
-
if (contentType === 'flow'
|
|
22
|
-
return 'a flow entry requires flow_id';
|
|
25
|
+
if (contentType === 'flow') {
|
|
26
|
+
return typeof flowId === 'string' ? null : 'a flow entry requires flow_id';
|
|
23
27
|
}
|
|
24
|
-
|
|
28
|
+
if (!A_B_TEST_KINDS.has(contentType)) {
|
|
29
|
+
return 'this CLI does not know this audience kind; update the CLI';
|
|
30
|
+
}
|
|
31
|
+
return typeof abTestId === 'string' ? null : `an entry with content_type "${contentType}" requires ab_test_id`;
|
|
25
32
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the analytics of an app can answer: the six operations with their options, groupings and filters, every
|
|
3
|
+
* chart and conversion of the dashboard, and every filter key. Passed through as the server sends it.
|
|
4
|
+
*/
|
|
5
|
+
/** A grouping: `key` is what `--group-by` takes. */
|
|
6
|
+
export type CatalogSegmentation = {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
};
|
|
10
|
+
/** One query field: `values` lists the accepted values when they are a fixed set. */
|
|
11
|
+
export type CatalogOption = {
|
|
12
|
+
default: boolean | null | string;
|
|
13
|
+
name: string;
|
|
14
|
+
values: null | string[];
|
|
15
|
+
};
|
|
16
|
+
export type CatalogOperation = {
|
|
17
|
+
can_compare: boolean;
|
|
18
|
+
/** Filter keys; for `metrics` they depend on the chart and are in `charts`. */
|
|
19
|
+
filters: string[];
|
|
20
|
+
id: string;
|
|
21
|
+
method: string;
|
|
22
|
+
options: CatalogOption[];
|
|
23
|
+
path: string;
|
|
24
|
+
segmentations: CatalogSegmentation[];
|
|
25
|
+
};
|
|
26
|
+
export type CatalogChart = {
|
|
27
|
+
default_segmentation: null | string;
|
|
28
|
+
filters: string[];
|
|
29
|
+
id: string;
|
|
30
|
+
segmentations: CatalogSegmentation[];
|
|
31
|
+
takes_revenue_basis: boolean;
|
|
32
|
+
title: string;
|
|
33
|
+
unit: string;
|
|
34
|
+
};
|
|
35
|
+
export type CatalogConversion = {
|
|
36
|
+
id: string;
|
|
37
|
+
title: string;
|
|
38
|
+
};
|
|
39
|
+
export type CatalogDimension = {
|
|
40
|
+
key: string;
|
|
41
|
+
label: string;
|
|
42
|
+
};
|
|
43
|
+
export type AnalyticsCatalog = {
|
|
44
|
+
charts: CatalogChart[];
|
|
45
|
+
conversions: CatalogConversion[];
|
|
46
|
+
dimensions: CatalogDimension[];
|
|
47
|
+
operations: CatalogOperation[];
|
|
48
|
+
};
|
|
49
|
+
export type CatalogResponse = {
|
|
50
|
+
data: AnalyticsCatalog;
|
|
51
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AnalyticsData, AnalyticsMeta, AnalyticsResponse, AnalyticsSegment, PeriodUnit, RevenueBasis } from './model.js';
|
|
2
|
+
import type { ComparableQueryInput, GroupingInput, GroupingRequest, QueryRequest } from './query.js';
|
|
3
|
+
import type { Issue } from '../../core/errors.js';
|
|
4
|
+
/** A chart id as the catalog lists it; the backend knows which ids exist. */
|
|
5
|
+
export type ChartId = string;
|
|
6
|
+
export type ChartInput = ComparableQueryInput & GroupingInput & {
|
|
7
|
+
chartId: ChartId;
|
|
8
|
+
/** Money charts only; the backend refuses it for the others (exit 4). */
|
|
9
|
+
revenueBasis?: RevenueBasis | undefined;
|
|
10
|
+
};
|
|
11
|
+
type ChartRequest = GroupingRequest & QueryRequest & {
|
|
12
|
+
chart_id: ChartId;
|
|
13
|
+
revenue_basis?: RevenueBasis;
|
|
14
|
+
};
|
|
15
|
+
export type ChartPoint = {
|
|
16
|
+
value: number;
|
|
17
|
+
x: string;
|
|
18
|
+
};
|
|
19
|
+
export type ChartSegment = AnalyticsSegment & {
|
|
20
|
+
value: number;
|
|
21
|
+
values: ChartPoint[];
|
|
22
|
+
};
|
|
23
|
+
export type ChartData = AnalyticsData<ChartSegment> & {
|
|
24
|
+
/** The compared period, built from that period only; null without one and when neither period has rows. */
|
|
25
|
+
compare: null | {
|
|
26
|
+
segments: ChartSegment[];
|
|
27
|
+
value: number;
|
|
28
|
+
};
|
|
29
|
+
value: number;
|
|
30
|
+
};
|
|
31
|
+
export type ChartMeta = AnalyticsMeta<{
|
|
32
|
+
chart_id: ChartId;
|
|
33
|
+
period_unit: PeriodUnit;
|
|
34
|
+
/** Null for a chart that is not about money. */
|
|
35
|
+
revenue_basis: null | RevenueBasis;
|
|
36
|
+
segmentation: null | string;
|
|
37
|
+
}>;
|
|
38
|
+
export type ChartResponse = AnalyticsResponse<ChartData, ChartMeta>;
|
|
39
|
+
export declare const validateChart: (input: ChartInput) => Issue[];
|
|
40
|
+
export declare const toChartRequest: (input: ChartInput) => ChartRequest;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { toGroupingRequest, toQueryRequest, validateComparableQuery } from './query.js';
|
|
2
|
+
export const validateChart = (input) => validateComparableQuery(input);
|
|
3
|
+
export const toChartRequest = (input) => {
|
|
4
|
+
const body = { chart_id: input.chartId, ...toQueryRequest(input), ...toGroupingRequest(input) };
|
|
5
|
+
return input.revenueBasis === undefined ? body : { ...body, revenue_basis: input.revenueBasis };
|
|
6
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AnalyticsData, AnalyticsMeta, AnalyticsResponse, AnalyticsSegment, Money, PeriodUnit } from './model.js';
|
|
2
|
+
import type { GroupingInput, GroupingRequest } from './query.js';
|
|
3
|
+
import type { RenewalPeriodsInput, RenewalPeriodsQuery, RenewalPeriodsRequest } from './renewal-periods.js';
|
|
4
|
+
import type { Issue } from '../../core/errors.js';
|
|
5
|
+
/** No grouping: each segment is one cohort already. `granularity` is the length of one cohort. */
|
|
6
|
+
export type CohortInput = Pick<GroupingInput, 'granularity'> & RenewalPeriodsInput;
|
|
7
|
+
type CohortRequest = Pick<GroupingRequest, 'period_unit'> & RenewalPeriodsRequest;
|
|
8
|
+
/**
|
|
9
|
+
* One period of a cohort. `value` and `value_relative` are the revenue on the asked basis; the money fields
|
|
10
|
+
* give each basis.
|
|
11
|
+
*/
|
|
12
|
+
export type CohortPoint = {
|
|
13
|
+
arpas: Money;
|
|
14
|
+
arppu: Money;
|
|
15
|
+
arpu: Money;
|
|
16
|
+
currently_active_period: boolean;
|
|
17
|
+
installs: number;
|
|
18
|
+
revenue: Money;
|
|
19
|
+
revenue_relative: Money;
|
|
20
|
+
subscribers: number;
|
|
21
|
+
subscribers_relative: number;
|
|
22
|
+
subscriptions: number;
|
|
23
|
+
subscriptions_relative: number;
|
|
24
|
+
value: number;
|
|
25
|
+
value_relative: number;
|
|
26
|
+
x: number;
|
|
27
|
+
};
|
|
28
|
+
/** Predicted revenue (`rev*`) and LTV (`ltv*`) after 3..24 months; null where the backend cannot predict. */
|
|
29
|
+
export type PredictValues = {
|
|
30
|
+
ltv3: null | number;
|
|
31
|
+
ltv6: null | number;
|
|
32
|
+
ltv9: null | number;
|
|
33
|
+
ltv12: null | number;
|
|
34
|
+
ltv18: null | number;
|
|
35
|
+
ltv24: null | number;
|
|
36
|
+
rev3: null | number;
|
|
37
|
+
rev6: null | number;
|
|
38
|
+
rev9: null | number;
|
|
39
|
+
rev12: null | number;
|
|
40
|
+
rev18: null | number;
|
|
41
|
+
rev24: null | number;
|
|
42
|
+
};
|
|
43
|
+
/** The backend predictions per basis (`gross_v2`: its second gross model); a section is null where it gives none. */
|
|
44
|
+
export type CohortPredict = {
|
|
45
|
+
gross: null | PredictValues;
|
|
46
|
+
gross_v2: null | PredictValues;
|
|
47
|
+
net: null | PredictValues;
|
|
48
|
+
proceeds: null | PredictValues;
|
|
49
|
+
status: null | {
|
|
50
|
+
status_3: null | number;
|
|
51
|
+
status_6: null | number;
|
|
52
|
+
status_9: null | number;
|
|
53
|
+
status_12: null | number;
|
|
54
|
+
status_18: null | number;
|
|
55
|
+
status_24: null | number;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/** One cohort, its start date as `key` (null for the total); `value` is its revenue on the asked basis. */
|
|
59
|
+
export type CohortSegment = AnalyticsSegment & {
|
|
60
|
+
arpas: Money;
|
|
61
|
+
arppu: Money;
|
|
62
|
+
arpu: Money;
|
|
63
|
+
installs: number;
|
|
64
|
+
predict: CohortPredict | null;
|
|
65
|
+
revenue: Money;
|
|
66
|
+
start_date: null | string;
|
|
67
|
+
subscribers: number;
|
|
68
|
+
subscriptions: number;
|
|
69
|
+
value: number;
|
|
70
|
+
values: CohortPoint[];
|
|
71
|
+
};
|
|
72
|
+
export type CohortData = AnalyticsData<CohortSegment>;
|
|
73
|
+
export type CohortMeta = AnalyticsMeta<RenewalPeriodsQuery & {
|
|
74
|
+
period_unit: PeriodUnit;
|
|
75
|
+
}>;
|
|
76
|
+
export type CohortResponse = AnalyticsResponse<CohortData, CohortMeta>;
|
|
77
|
+
export declare const validateCohort: (input: CohortInput) => Issue[];
|
|
78
|
+
export declare const toCohortRequest: (input: CohortInput) => CohortRequest;
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { toGroupingRequest } from './query.js';
|
|
2
|
+
import { toRenewalPeriodsRequest, validateRenewalPeriods } from './renewal-periods.js';
|
|
3
|
+
export const validateCohort = (input) => validateRenewalPeriods(input);
|
|
4
|
+
export const toCohortRequest = (input) => ({ ...toRenewalPeriodsRequest(input), ...toGroupingRequest(input) });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AnalyticsData, AnalyticsMeta, AnalyticsResponse, AnalyticsSegment, PeriodUnit } from './model.js';
|
|
2
|
+
import type { ComparableQueryInput, GroupingInput, GroupingRequest, QueryRequest } from './query.js';
|
|
3
|
+
import type { Issue } from '../../core/errors.js';
|
|
4
|
+
/** A conversion id as the catalog lists it; the backend knows which ids exist and never maps one id to another pair. */
|
|
5
|
+
export type ConversionId = string;
|
|
6
|
+
export type ConversionInput = ComparableQueryInput & GroupingInput & {
|
|
7
|
+
conversionId: ConversionId;
|
|
8
|
+
};
|
|
9
|
+
type ConversionRequest = GroupingRequest & QueryRequest & {
|
|
10
|
+
conversion_id: ConversionId;
|
|
11
|
+
};
|
|
12
|
+
export type ConversionPoint = {
|
|
13
|
+
has_unfinished: boolean;
|
|
14
|
+
value: number;
|
|
15
|
+
value_from: number;
|
|
16
|
+
value_from_unfinished: number;
|
|
17
|
+
value_to: number;
|
|
18
|
+
x: string;
|
|
19
|
+
};
|
|
20
|
+
/** `value` is the conversion in percent: `value_to` of `value_from`. */
|
|
21
|
+
/** `value_from` and `value_to` are null only on a compared total that the backend does not give. */
|
|
22
|
+
export type ConversionSegment = AnalyticsSegment & {
|
|
23
|
+
value: number;
|
|
24
|
+
value_from: null | number;
|
|
25
|
+
value_to: null | number;
|
|
26
|
+
values: ConversionPoint[];
|
|
27
|
+
};
|
|
28
|
+
export type ConversionData = AnalyticsData<ConversionSegment> & {
|
|
29
|
+
/** The counts of the compared period itself; null when the backend has no total row for it. */
|
|
30
|
+
compare: null | {
|
|
31
|
+
segments: ConversionSegment[];
|
|
32
|
+
value: number;
|
|
33
|
+
value_from: null | number;
|
|
34
|
+
value_to: null | number;
|
|
35
|
+
};
|
|
36
|
+
value: number;
|
|
37
|
+
value_from: number;
|
|
38
|
+
value_to: number;
|
|
39
|
+
};
|
|
40
|
+
export type ConversionMeta = AnalyticsMeta<{
|
|
41
|
+
conversion_id: ConversionId;
|
|
42
|
+
period_unit: PeriodUnit;
|
|
43
|
+
segmentation: null | string;
|
|
44
|
+
}> & {
|
|
45
|
+
/** A window with data, given when the asked window has none. */
|
|
46
|
+
recommended_period: null | {
|
|
47
|
+
date_from: string;
|
|
48
|
+
date_to: string;
|
|
49
|
+
period_unit: string;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export type ConversionResponse = AnalyticsResponse<ConversionData, ConversionMeta>;
|
|
53
|
+
export declare const validateConversion: (input: ConversionInput) => Issue[];
|
|
54
|
+
export declare const toConversionRequest: (input: ConversionInput) => ConversionRequest;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { toGroupingRequest, toQueryRequest, validateComparableQuery } from './query.js';
|
|
2
|
+
export const validateConversion = (input) => validateComparableQuery(input);
|
|
3
|
+
export const toConversionRequest = (input) => ({
|
|
4
|
+
conversion_id: input.conversionId,
|
|
5
|
+
...toQueryRequest(input),
|
|
6
|
+
...toGroupingRequest(input),
|
|
7
|
+
});
|