adapty 0.11.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/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 +884 -120
- package/package.json +4 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { validateLtv } from '../../../sdk/adapty/index.js';
|
|
2
|
+
import { assertValid } from '../../../sdk/core/validation.js';
|
|
3
|
+
import { AdaptyCommand, csvFlag, filterFlags, groupByFlag, groupingParams, periodFlags, queryParams, renewalPeriodFlags, revenueBasisFlag, } from '../../base/adapty/index.js';
|
|
4
|
+
import { appIdFlag } from '../../input/app.js';
|
|
5
|
+
import { analyticsView, ltvTable } from '../../views/analytics/human.js';
|
|
6
|
+
import { csvNotice } from '../../views/analytics/segments.js';
|
|
7
|
+
export default class AnalyticsLtv extends AdaptyCommand {
|
|
8
|
+
static description = 'Read the LTV of an app: the revenue per paying user after each renewal (or day), '
|
|
9
|
+
+ 'per group, the total first (no compared period)';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> analytics ltv --app APP_UUID --date-from 2026-07-01 --date-to 2026-09-30',
|
|
12
|
+
'<%= config.bin %> analytics ltv --app APP_UUID --date-from 2026-07-01 --date-to 2026-09-30 --revenue-basis net --period-type days --renewal-days 7,30,90 --csv',
|
|
13
|
+
];
|
|
14
|
+
static flags = {
|
|
15
|
+
...appIdFlag,
|
|
16
|
+
...periodFlags,
|
|
17
|
+
...groupByFlag,
|
|
18
|
+
...filterFlags,
|
|
19
|
+
...revenueBasisFlag,
|
|
20
|
+
...renewalPeriodFlags,
|
|
21
|
+
...csvFlag,
|
|
22
|
+
};
|
|
23
|
+
async run() {
|
|
24
|
+
const { flags } = await this.parse(AnalyticsLtv);
|
|
25
|
+
const input = {
|
|
26
|
+
...queryParams(flags),
|
|
27
|
+
...groupingParams(flags),
|
|
28
|
+
periodType: flags['period-type'],
|
|
29
|
+
renewalDays: flags['renewal-days'],
|
|
30
|
+
revenueBasis: flags['revenue-basis'],
|
|
31
|
+
};
|
|
32
|
+
// Before this.adapty: bad input is exit 2 even without a token
|
|
33
|
+
assertValid(validateLtv(input));
|
|
34
|
+
const answer = await this.adapty.analytics.ltv(input);
|
|
35
|
+
this.render(answer, analyticsView(flags.csv, ltvTable));
|
|
36
|
+
// Beside a CSV of a period without data, its summary; an empty text otherwise
|
|
37
|
+
process.stderr.write(csvNotice(answer, flags.csv));
|
|
38
|
+
return answer;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AdaptyCommand } from '../../base/adapty/index.js';
|
|
2
|
+
import type { RetentionResponse } from '../../../sdk/adapty/index.js';
|
|
3
|
+
export default class AnalyticsRetention extends AdaptyCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
csv: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'use-trial': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
filter: import("@oclif/core/interfaces").OptionFlag<import("../../../sdk/adapty/index.js").AnalyticsFilter[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
timezone: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'group-by': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
'date-from': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
'date-to': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
};
|
|
16
|
+
run(): Promise<RetentionResponse>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { validateRetention } from '../../../sdk/adapty/index.js';
|
|
3
|
+
import { assertValid } from '../../../sdk/core/validation.js';
|
|
4
|
+
import { AdaptyCommand, csvFlag, filterFlags, groupByFlag, groupingParams, periodFlags, queryParams, } from '../../base/adapty/index.js';
|
|
5
|
+
import { appIdFlag } from '../../input/app.js';
|
|
6
|
+
import { analyticsView, retentionTable } from '../../views/analytics/human.js';
|
|
7
|
+
import { csvNotice } from '../../views/analytics/segments.js';
|
|
8
|
+
export default class AnalyticsRetention extends AdaptyCommand {
|
|
9
|
+
static description = 'Read the retention of an app: the subscribers left in each period, per group, '
|
|
10
|
+
+ 'the total first (no compared period)';
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> analytics retention --app APP_UUID --date-from 2026-07-01 --date-to 2026-09-30',
|
|
13
|
+
'<%= config.bin %> analytics retention --app APP_UUID --date-from 2026-07-01 --date-to 2026-09-30 --group-by country --use-trial --csv',
|
|
14
|
+
];
|
|
15
|
+
static flags = {
|
|
16
|
+
...appIdFlag,
|
|
17
|
+
...periodFlags,
|
|
18
|
+
...groupByFlag,
|
|
19
|
+
...filterFlags,
|
|
20
|
+
'use-trial': Flags.boolean({
|
|
21
|
+
description: 'Count from the trial, not from the first paid period',
|
|
22
|
+
}),
|
|
23
|
+
...csvFlag,
|
|
24
|
+
};
|
|
25
|
+
async run() {
|
|
26
|
+
const { flags } = await this.parse(AnalyticsRetention);
|
|
27
|
+
const input = {
|
|
28
|
+
...queryParams(flags),
|
|
29
|
+
...groupingParams(flags),
|
|
30
|
+
// Sent only when given: the backend default (false) applies otherwise, and meta.query says so
|
|
31
|
+
useTrial: flags['use-trial'] ? true : undefined,
|
|
32
|
+
};
|
|
33
|
+
// Before this.adapty: bad input is exit 2 even without a token
|
|
34
|
+
assertValid(validateRetention(input));
|
|
35
|
+
const answer = await this.adapty.analytics.retention(input);
|
|
36
|
+
this.render(answer, analyticsView(flags.csv, retentionTable));
|
|
37
|
+
// Beside a CSV of a period without data, its summary; an empty text otherwise
|
|
38
|
+
process.stderr.write(csvNotice(answer, flags.csv));
|
|
39
|
+
return answer;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { AdaptyCommand } from '../../base/adapty/index.js';
|
|
2
|
+
import type { FilterValuesResponse } from '../../../sdk/adapty/index.js';
|
|
3
|
+
export default class AnalyticsValues extends AdaptyCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
dimension: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<FilterValuesResponse>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { validateFilterValues } from '../../../sdk/adapty/index.js';
|
|
3
|
+
import { assertValid } from '../../../sdk/core/validation.js';
|
|
4
|
+
import { AdaptyCommand } from '../../base/adapty/index.js';
|
|
5
|
+
import { appIdFlag } from '../../input/app.js';
|
|
6
|
+
/** The value first, as `--filter <dimension>=<value>` takes it; an empty value is shown as `""`. */
|
|
7
|
+
const describeValue = (item) => `${item.value === '' ? '""' : item.value}: ${item.label}${item.group === null ? '' : ` (${item.group})`}`;
|
|
8
|
+
const renderValues = ({ data, meta }) => (data.length === 0
|
|
9
|
+
? `No ${meta.dimension} values for this app.`
|
|
10
|
+
: data.map(item => describeValue(item)).join('\n'));
|
|
11
|
+
export default class AnalyticsValues extends AdaptyCommand {
|
|
12
|
+
static description = 'List the values one analytics filter takes for an app: what --filter accepts';
|
|
13
|
+
static examples = [
|
|
14
|
+
'<%= config.bin %> analytics values --app APP_UUID --dimension country',
|
|
15
|
+
'<%= config.bin %> analytics values --app APP_UUID --dimension store_product_id --json',
|
|
16
|
+
];
|
|
17
|
+
static flags = {
|
|
18
|
+
...appIdFlag,
|
|
19
|
+
dimension: Flags.string({
|
|
20
|
+
description: 'A filter dimension, as `adapty analytics catalog` lists it under Dimensions',
|
|
21
|
+
required: true,
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
async run() {
|
|
25
|
+
const { flags } = await this.parse(AnalyticsValues);
|
|
26
|
+
const input = { appId: flags.app, dimension: flags.dimension };
|
|
27
|
+
// Before this.adapty: bad input is exit 2 even without a token
|
|
28
|
+
assertValid(validateFilterValues(input));
|
|
29
|
+
const values = await this.adapty.analytics.values(input);
|
|
30
|
+
this.render(values, renderValues);
|
|
31
|
+
return values;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -4,20 +4,15 @@ import { assertValid } from '../../../../sdk/core/validation.js';
|
|
|
4
4
|
import { AttributionCommand, periodFlags, periodParams, revenueBasisFlag } from '../../../base/attribution/index.js';
|
|
5
5
|
import { usageError } from '../../../errors.js';
|
|
6
6
|
import { appIdFlag } from '../../../input/app.js';
|
|
7
|
+
import { splitFilter } from '../../../input/filter.js';
|
|
7
8
|
import { renderReport } from './lib/render.js';
|
|
8
|
-
/**
|
|
9
|
-
const VALUE_SEPARATOR = /(?<!\\),/;
|
|
10
|
-
/** `dimension=value[,value]`: one value filters by equality, several by any of them. */
|
|
9
|
+
/** `dimension=value[,value]`: one value filters by equality, several by any; values trimmed, empties dropped. */
|
|
11
10
|
const parseFilter = (input) => {
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
? []
|
|
16
|
-
: input.slice(separator + 1).split(VALUE_SEPARATOR).map(value => value.replaceAll('\\,', ',').trim())
|
|
17
|
-
.filter(value => value !== '');
|
|
18
|
-
return dimension === '' || values.length === 0
|
|
11
|
+
const filter = splitFilter(input);
|
|
12
|
+
const values = filter?.values.map(value => value.trim()).filter(value => value !== '') ?? [];
|
|
13
|
+
return filter === undefined || values.length === 0
|
|
19
14
|
? Promise.reject(usageError(`Expected dimension=value[,value], got "${input}".`))
|
|
20
|
-
: Promise.resolve({ dimension, values });
|
|
15
|
+
: Promise.resolve({ dimension: filter.dimension, values });
|
|
21
16
|
};
|
|
22
17
|
const isDirection = (value) => sortDirections.includes(value);
|
|
23
18
|
/** `field[:asc|desc]`, ascending when the direction is left out. */
|
package/dist/cli/errors.js
CHANGED
|
@@ -88,7 +88,14 @@ export const describeCleanupFailures = (files, results) => {
|
|
|
88
88
|
};
|
|
89
89
|
/** Issue.path is a camelCase sdk field; the user typed a kebab-case flag. */
|
|
90
90
|
const flagName = (path) => `--${path.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)}`;
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A rule names another input field as `{fieldName}` and lists it in `fields`: the user reads the flag they would
|
|
93
|
+
* type. Only the listed names are written, so other braces in a message stay as they are.
|
|
94
|
+
*/
|
|
95
|
+
const describeIssue = (issue) => {
|
|
96
|
+
const message = (issue.fields ?? []).reduce((text, field) => text.replaceAll(`{${field}}`, flagName(field)), issue.message);
|
|
97
|
+
return issue.path === undefined ? message : `${flagName(issue.path)}: ${message}`;
|
|
98
|
+
};
|
|
92
99
|
/**
|
|
93
100
|
* The one place where an sdk error gets a human text and an exit code. No default in the switch:
|
|
94
101
|
* a new kind fails to compile until it is mapped here.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** One `--filter`: a dimension and the values it may take (one value is equality, several any of them). */
|
|
2
|
+
export type DimensionFilter = {
|
|
3
|
+
dimension: string;
|
|
4
|
+
values: string[];
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* `dimension=value[,value]` split as written: the dimension before the first `=`, trimmed; every value after it
|
|
8
|
+
* as typed, `\,` turned into a comma. Undefined without `=` or without a dimension. What a product does with
|
|
9
|
+
* spaces and empty values is its own rule, so its flag parser decides that.
|
|
10
|
+
*/
|
|
11
|
+
export declare const splitFilter: (input: string) => DimensionFilter | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A comma not preceded by a backslash: `\,` keeps a comma inside a value, as oclif's own delimiter does. */
|
|
2
|
+
const VALUE_SEPARATOR = /(?<!\\),/;
|
|
3
|
+
/**
|
|
4
|
+
* `dimension=value[,value]` split as written: the dimension before the first `=`, trimmed; every value after it
|
|
5
|
+
* as typed, `\,` turned into a comma. Undefined without `=` or without a dimension. What a product does with
|
|
6
|
+
* spaces and empty values is its own rule, so its flag parser decides that.
|
|
7
|
+
*/
|
|
8
|
+
export const splitFilter = (input) => {
|
|
9
|
+
const separator = input.indexOf('=');
|
|
10
|
+
const dimension = separator === -1 ? '' : input.slice(0, separator).trim();
|
|
11
|
+
if (dimension === '') {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const values = input.slice(separator + 1).split(VALUE_SEPARATOR).map(value => value.replaceAll('\\,', ','));
|
|
15
|
+
return { dimension, values };
|
|
16
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The shape only: whether the day exists and the order of the two are sdk rules. */
|
|
2
|
+
export declare const dateParser: (input: string) => Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* An inclusive day range, both ends required. `zone` completes the help text: which time zone the days are in,
|
|
5
|
+
* the one thing the products say differently.
|
|
6
|
+
*/
|
|
7
|
+
export declare const dayRangeFlags: (zone: string) => {
|
|
8
|
+
'date-from': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
'date-to': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { ISO_DATE } from '../../sdk/core/dates.js';
|
|
3
|
+
import { usageError } from '../errors.js';
|
|
4
|
+
/** The shape only: whether the day exists and the order of the two are sdk rules. */
|
|
5
|
+
export const dateParser = (input) => (ISO_DATE.test(input)
|
|
6
|
+
? Promise.resolve(input)
|
|
7
|
+
: Promise.reject(usageError('Dates must be written as YYYY-MM-DD.')));
|
|
8
|
+
/**
|
|
9
|
+
* An inclusive day range, both ends required. `zone` completes the help text: which time zone the days are in,
|
|
10
|
+
* the one thing the products say differently.
|
|
11
|
+
*/
|
|
12
|
+
export const dayRangeFlags = (zone) => ({
|
|
13
|
+
'date-from': Flags.string({
|
|
14
|
+
description: `First day of the period, inclusive (YYYY-MM-DD, ${zone})`,
|
|
15
|
+
parse: dateParser,
|
|
16
|
+
required: true,
|
|
17
|
+
}),
|
|
18
|
+
'date-to': Flags.string({
|
|
19
|
+
description: `Last day of the period, inclusive (YYYY-MM-DD, ${zone})`,
|
|
20
|
+
parse: dateParser,
|
|
21
|
+
required: true,
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4180: a field with a comma, a double quote or a line break goes in double quotes, and a quote inside is
|
|
3
|
+
* doubled. A missing value (null) is an empty cell, so it never reads as 0; an empty text (a group key, for
|
|
4
|
+
* example) is `""`, so it never reads as a missing one.
|
|
5
|
+
*/
|
|
6
|
+
const csvCell = (value) => {
|
|
7
|
+
if (value === null || value === undefined) {
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
if (value === '') {
|
|
11
|
+
return '""';
|
|
12
|
+
}
|
|
13
|
+
const text = String(value);
|
|
14
|
+
return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
15
|
+
};
|
|
16
|
+
export const csvLine = (cells) => cells.map(cell => csvCell(cell)).join(',');
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AnalyticsAnswer } from './segments.js';
|
|
2
|
+
/**
|
|
3
|
+
* The human view of the six analytics operations: a short table, one row per group (the total first), the main
|
|
4
|
+
* numbers of the group and one column per point (a date, a funnel step, a period). The resolved query gives the
|
|
5
|
+
* heading. Where an operation has more fields than one table holds, a line under it says that `--json` and
|
|
6
|
+
* `--csv` give every field.
|
|
7
|
+
*/
|
|
8
|
+
type Query = Record<string, unknown>;
|
|
9
|
+
/** How a number reads: money with 2 decimals, any other number as the API gives it, null as `—`. */
|
|
10
|
+
type Format = (value: unknown) => string;
|
|
11
|
+
/** One number of a group: its header and the segment field it shows. */
|
|
12
|
+
type Column = {
|
|
13
|
+
field: string;
|
|
14
|
+
format: Format;
|
|
15
|
+
header: string;
|
|
16
|
+
};
|
|
17
|
+
export type HumanTable = {
|
|
18
|
+
/** The numbers of a group after its title, the main value first. */
|
|
19
|
+
columns: (query: Query) => Column[];
|
|
20
|
+
/** The fields the table leaves out; none when it shows every number of the answer. */
|
|
21
|
+
omitted?: string;
|
|
22
|
+
/** How the value of a point reads. */
|
|
23
|
+
points: (query: Query) => Format;
|
|
24
|
+
/** What the answer is, from the resolved query: the operation, then its options. */
|
|
25
|
+
title: (query: Query) => (string | undefined)[];
|
|
26
|
+
};
|
|
27
|
+
export declare const chartTable: HumanTable;
|
|
28
|
+
export declare const conversionTable: HumanTable;
|
|
29
|
+
export declare const funnelTable: HumanTable;
|
|
30
|
+
export declare const retentionTable: HumanTable;
|
|
31
|
+
export declare const ltvTable: HumanTable;
|
|
32
|
+
export declare const cohortTable: HumanTable;
|
|
33
|
+
/**
|
|
34
|
+
* The heading (what, by which grouping; the period, its time zone and filters), then the table of the asked
|
|
35
|
+
* period and, when there is one, of the compared period. A period without groups: `No data for this period.` and
|
|
36
|
+
* the summary of that period, every field, null as `—`.
|
|
37
|
+
*/
|
|
38
|
+
export declare const humanView: (spec: HumanTable) => (answer: AnalyticsAnswer) => string;
|
|
39
|
+
/** The view of a data command: the CSV with `--csv`, else the human table (`--json` prints no view at all). */
|
|
40
|
+
export declare const analyticsView: (csv: boolean, spec: HumanTable) => (answer: AnalyticsAnswer) => string;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { formatValue, line, NO_DATA, NO_VALUE, pointId, pointLabel, renderCsv, sectionsOf, } from './segments.js';
|
|
2
|
+
const asGiven = (value) => {
|
|
3
|
+
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
|
|
4
|
+
return String(value);
|
|
5
|
+
}
|
|
6
|
+
return NO_VALUE;
|
|
7
|
+
};
|
|
8
|
+
const money = value => (typeof value === 'number' ? value.toFixed(2) : asGiven(value));
|
|
9
|
+
const text = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
|
|
10
|
+
const groupedBy = (query) => {
|
|
11
|
+
const segmentation = text(query.segmentation);
|
|
12
|
+
return segmentation === undefined ? undefined : `by ${segmentation}`;
|
|
13
|
+
};
|
|
14
|
+
const perPeriod = (query) => {
|
|
15
|
+
if (query.period_type === 'renewals') {
|
|
16
|
+
return 'per renewal';
|
|
17
|
+
}
|
|
18
|
+
return query.period_type === 'days' ? 'per day' : undefined;
|
|
19
|
+
};
|
|
20
|
+
const isMoneyChart = (query) => text(query.revenue_basis) !== undefined;
|
|
21
|
+
export const chartTable = {
|
|
22
|
+
columns: query => [{ field: 'value', format: isMoneyChart(query) ? money : asGiven, header: 'Total' }],
|
|
23
|
+
points: query => (isMoneyChart(query) ? money : asGiven),
|
|
24
|
+
title: query => [['Chart', text(query.chart_id)].filter(part => part !== undefined).join(' '), text(query.revenue_basis), groupedBy(query)],
|
|
25
|
+
};
|
|
26
|
+
export const conversionTable = {
|
|
27
|
+
columns: () => [
|
|
28
|
+
{ field: 'value', format: asGiven, header: 'Conversion %' },
|
|
29
|
+
{ field: 'value_from', format: asGiven, header: 'From' },
|
|
30
|
+
{ field: 'value_to', format: asGiven, header: 'To' },
|
|
31
|
+
],
|
|
32
|
+
omitted: 'the counts of each point (value_from, value_to, value_from_unfinished, has_unfinished)',
|
|
33
|
+
points: () => asGiven,
|
|
34
|
+
title: query => [['Conversion', text(query.conversion_id)].filter(part => part !== undefined).join(' '), groupedBy(query)],
|
|
35
|
+
};
|
|
36
|
+
export const funnelTable = {
|
|
37
|
+
columns: () => [{ field: 'value', format: asGiven, header: 'Installs' }],
|
|
38
|
+
omitted: 'the share of each step, the change to the next step, churn reasons and parts',
|
|
39
|
+
points: () => asGiven,
|
|
40
|
+
title: query => ['Funnel', groupedBy(query)],
|
|
41
|
+
};
|
|
42
|
+
export const retentionTable = {
|
|
43
|
+
columns: () => [{ field: 'value', format: asGiven, header: 'Subscribers' }],
|
|
44
|
+
omitted: 'the share of each period (value_relative)',
|
|
45
|
+
points: () => asGiven,
|
|
46
|
+
title: query => ['Retention', query.use_trial === true ? 'from the trial' : undefined, groupedBy(query)],
|
|
47
|
+
};
|
|
48
|
+
export const ltvTable = {
|
|
49
|
+
columns: () => [
|
|
50
|
+
{ field: 'value', format: money, header: 'LTV' },
|
|
51
|
+
{ field: 'cohort_size', format: asGiven, header: 'Cohort size' },
|
|
52
|
+
],
|
|
53
|
+
points: () => money,
|
|
54
|
+
title: query => ['LTV', text(query.revenue_basis), perPeriod(query), groupedBy(query)],
|
|
55
|
+
};
|
|
56
|
+
export const cohortTable = {
|
|
57
|
+
columns: () => [
|
|
58
|
+
{ field: 'value', format: money, header: 'Revenue' },
|
|
59
|
+
{ field: 'installs', format: asGiven, header: 'Installs' },
|
|
60
|
+
{ field: 'subscribers', format: asGiven, header: 'Subscribers' },
|
|
61
|
+
],
|
|
62
|
+
omitted: 'revenue, ARPU, ARPPU and ARPAS on each basis, subscriptions, the shares of each period, predictions',
|
|
63
|
+
points: () => money,
|
|
64
|
+
title: query => ['Cohorts', text(query.revenue_basis), perPeriod(query)],
|
|
65
|
+
};
|
|
66
|
+
const range = (dates) => (Array.isArray(dates) && dates.length > 0 ? dates.join(' – ') : undefined);
|
|
67
|
+
const PERIOD_KEYS = new Set(['compare_date', 'date']);
|
|
68
|
+
/** The filters besides the period, as `--filter` takes them. */
|
|
69
|
+
const filtersOf = (filters) => {
|
|
70
|
+
const given = Object.entries(filters).filter(([dimension]) => !PERIOD_KEYS.has(dimension))
|
|
71
|
+
.map(([dimension, values]) => `${dimension}=${values.map(value => value.replaceAll(',', String.raw `\,`)).join(',')}`);
|
|
72
|
+
return given.length === 0 ? undefined : `filters ${given.join(' ')}`;
|
|
73
|
+
};
|
|
74
|
+
const TIMEZONE_SOURCES = { app: 'from the app', request: 'from --timezone' };
|
|
75
|
+
/** The period, the length of one point, the time zone and where it comes from, and the filters. */
|
|
76
|
+
const periodLine = (query) => {
|
|
77
|
+
const filters = (query.filters ?? {});
|
|
78
|
+
const source = TIMEZONE_SOURCES[String(query.timezone_source)];
|
|
79
|
+
const zone = text(query.timezone);
|
|
80
|
+
const timezone = zone === undefined ? undefined : `time zone ${zone}${source === undefined ? '' : ` (${source})`}`;
|
|
81
|
+
return [range(filters.date), text(query.period_unit), timezone, filtersOf(filters)]
|
|
82
|
+
.filter(part => part !== undefined).join(', ');
|
|
83
|
+
};
|
|
84
|
+
/** Rows of cells, the first column left-aligned, the numbers right-aligned, two spaces between. */
|
|
85
|
+
const table = (rows) => {
|
|
86
|
+
const widths = rows[0]?.map((_, column) => Math.max(...rows.map(row => row[column]?.length ?? 0))) ?? [];
|
|
87
|
+
return rows.map(row => row.map((cell, column) => (column === 0
|
|
88
|
+
? cell.padEnd(widths[column] ?? 0)
|
|
89
|
+
: cell.padStart(widths[column] ?? 0))).join(' ').trimEnd()).join('\n');
|
|
90
|
+
};
|
|
91
|
+
const fieldOf = (value, name) => value[name];
|
|
92
|
+
/** One row per group, the total first (as `sectionsOf` orders them), then one column per point in first-seen order. */
|
|
93
|
+
const tableOf = (spec, query, segments) => {
|
|
94
|
+
const columns = spec.columns(query);
|
|
95
|
+
const format = spec.points(query);
|
|
96
|
+
const points = new Map();
|
|
97
|
+
for (const point of segments.flatMap(segment => segment.values)) {
|
|
98
|
+
if (!points.has(pointId(point))) {
|
|
99
|
+
points.set(pointId(point), pointLabel(point));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const rows = segments.map(segment => [
|
|
103
|
+
formatValue(segment.title),
|
|
104
|
+
...columns.map(column => column.format(fieldOf(segment, column.field))),
|
|
105
|
+
...[...points.keys()].map(id => format(fieldOf(segment.values.find(point => pointId(point) === id) ?? {}, 'value'))),
|
|
106
|
+
]);
|
|
107
|
+
return table([['', ...columns.map(column => column.header), ...points.values()], ...rows]);
|
|
108
|
+
};
|
|
109
|
+
const comparedHeading = (query) => {
|
|
110
|
+
const dates = range((query.filters ?? {}).compare_date);
|
|
111
|
+
return dates === undefined ? 'Compared period' : `Compared period: ${dates}`;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* The heading (what, by which grouping; the period, its time zone and filters), then the table of the asked
|
|
115
|
+
* period and, when there is one, of the compared period. A period without groups: `No data for this period.` and
|
|
116
|
+
* the summary of that period, every field, null as `—`.
|
|
117
|
+
*/
|
|
118
|
+
export const humanView = (spec) => (answer) => {
|
|
119
|
+
const query = answer.meta.query;
|
|
120
|
+
const sections = sectionsOf(answer);
|
|
121
|
+
const bodies = sections.map((section) => {
|
|
122
|
+
const heading = section.period === 'compare' ? [comparedHeading(query)] : [];
|
|
123
|
+
if (section.segments.length === 0) {
|
|
124
|
+
return [...heading, NO_DATA, ...section.summary.map(field => line(field))].join('\n');
|
|
125
|
+
}
|
|
126
|
+
return [...heading, tableOf(spec, query, section.segments)].join('\n');
|
|
127
|
+
});
|
|
128
|
+
const hasTable = sections.some(section => section.segments.length > 0);
|
|
129
|
+
const more = spec.omitted !== undefined && hasTable ? [`--json and --csv give every field: ${spec.omitted}.`] : [];
|
|
130
|
+
const title = spec.title(query).filter(part => part !== undefined).join(', ');
|
|
131
|
+
return [[title, periodLine(query)].join('\n'), ...bodies, ...more].join('\n\n');
|
|
132
|
+
};
|
|
133
|
+
/** The view of a data command: the CSV with `--csv`, else the human table (`--json` prints no view at all). */
|
|
134
|
+
export const analyticsView = (csv, spec) => (answer) => (csv ? renderCsv(answer) : humanView(spec)(answer));
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Cell } from './csv.js';
|
|
2
|
+
import type { AnalyticsSegment } from '../../../sdk/adapty/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* The CSV of the six analytics operations and the summary of a period, made from the answer itself: every field
|
|
5
|
+
* the API gives is printed, under the name it has in --json, so a field the backend adds is never dropped. A nested
|
|
6
|
+
* field has a dotted name (`revenue.gross`, `following_changes.value`), a list item its index (`parts[0].value`).
|
|
7
|
+
* The human table, which shows the main fields only, is in `human.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export type Segment = AnalyticsSegment & {
|
|
10
|
+
values: readonly object[];
|
|
11
|
+
};
|
|
12
|
+
type Segments = {
|
|
13
|
+
segments: readonly Segment[];
|
|
14
|
+
};
|
|
15
|
+
/** What every analytics answer has; `compare` only a chart and a conversion, null without a compared period. */
|
|
16
|
+
export type AnalyticsAnswer = {
|
|
17
|
+
data: Segments & {
|
|
18
|
+
compare?: null | Segments;
|
|
19
|
+
};
|
|
20
|
+
meta: {
|
|
21
|
+
query: object;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
type Field = readonly [name: string, value: Cell];
|
|
25
|
+
/** `main` is the asked period; `compare` the compared one. */
|
|
26
|
+
type Section = {
|
|
27
|
+
period: 'compare' | 'main';
|
|
28
|
+
segments: readonly Segment[];
|
|
29
|
+
/** The numbers of the whole period and, for the asked one, the resolved query and the other `meta` fields. */
|
|
30
|
+
summary: Field[];
|
|
31
|
+
};
|
|
32
|
+
/** A number the backend does not have is null, and printing it as 0 would be a lie. */
|
|
33
|
+
export declare const NO_VALUE = "\u2014";
|
|
34
|
+
export declare const NO_DATA = "No data for this period.";
|
|
35
|
+
/** An empty text (the key of a "No Data" group, for example) is shown as `""`, so it never reads as a missing value. */
|
|
36
|
+
export declare const formatValue: (value: Cell) => string;
|
|
37
|
+
/** A point is found by its `x`. */
|
|
38
|
+
export declare const pointId: (point: object) => string;
|
|
39
|
+
/**
|
|
40
|
+
* How the human view names a point: its `x`, and for a funnel step or a retention period also its title, the
|
|
41
|
+
* number once (`-2 Install`, `1 Paid`, `2 Period`).
|
|
42
|
+
*/
|
|
43
|
+
export declare const pointLabel: (point: object) => string;
|
|
44
|
+
/**
|
|
45
|
+
* The summary of the asked period: every field of `data` besides the segments, every field of `meta.query` (the
|
|
46
|
+
* resolved query: the time zone, the filters, the defaults) and every other field of `meta` (a conversion's
|
|
47
|
+
* `recommended_period`), named as in their object. Of a compared period: the fields of `data.compare`.
|
|
48
|
+
*/
|
|
49
|
+
export declare const sectionsOf: (answer: AnalyticsAnswer) => Section[];
|
|
50
|
+
export declare const line: ([name, value]: Field) => string;
|
|
51
|
+
/**
|
|
52
|
+
* The CSV view, made from the API answer: a header line, then one row per group, the total first (`is_total`
|
|
53
|
+
* true; its `key` cell is empty, as is the cell of a group whose raw key is null; a key `""` is written `""`).
|
|
54
|
+
* Columns: `key`, `title`, `is_total`; the numbers of the group in the asked period, then each point's `value`
|
|
55
|
+
* (named by its `x`) and its other fields as `<x>.<field>` (a funnel step's or retention period's title as
|
|
56
|
+
* `<x>.title`); the same for the compared period under `compare.`; then every other field of the answer under its
|
|
57
|
+
* JSON path (`data.value`, `data.compare.value`, `meta.query.timezone`, `meta.query.revenue_basis`, ...), the same
|
|
58
|
+
* on every row. A group of one period only has empty cells in the other. A null value is an empty cell. No groups:
|
|
59
|
+
* the header line alone.
|
|
60
|
+
*/
|
|
61
|
+
export declare const renderCsv: (answer: AnalyticsAnswer) => string;
|
|
62
|
+
/**
|
|
63
|
+
* What a data command writes to stderr: with `--csv`, when the asked period has no segments, `No data for this
|
|
64
|
+
* period.` and the summary of each period, as the human view shows them (the CSV is then its header alone, or the
|
|
65
|
+
* groups of the compared period); else nothing, an empty text.
|
|
66
|
+
*/
|
|
67
|
+
export declare const csvNotice: (answer: AnalyticsAnswer, csv: boolean) => string;
|
|
68
|
+
export {};
|