adapty 0.11.0 → 0.13.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 +45 -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/commands/product-offers/create.d.ts +15 -0
- package/dist/cli/commands/product-offers/create.js +41 -0
- package/dist/cli/commands/product-offers/get.d.ts +13 -0
- package/dist/cli/commands/product-offers/get.js +16 -0
- package/dist/cli/commands/product-offers/list.d.ts +11 -0
- package/dist/cli/commands/product-offers/list.js +15 -0
- package/dist/cli/commands/product-offers/update.d.ts +20 -0
- package/dist/cli/commands/product-offers/update.js +41 -0
- package/dist/cli/errors.js +8 -1
- package/dist/cli/input/app.js +1 -11
- package/dist/cli/input/filter.d.ts +11 -0
- package/dist/cli/input/filter.js +16 -0
- package/dist/cli/input/offer.d.ts +3 -0
- package/dist/cli/input/offer.js +9 -0
- package/dist/cli/input/period.d.ts +10 -0
- package/dist/cli/input/period.js +23 -0
- package/dist/cli/input/product.d.ts +3 -0
- package/dist/cli/input/product.js +9 -0
- package/dist/cli/input/uuid.d.ts +6 -0
- package/dist/cli/input/uuid.js +11 -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 +189 -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/product-offers/create.d.ts +1 -0
- package/dist/commands/product-offers/create.js +2 -0
- package/dist/commands/product-offers/get.d.ts +1 -0
- package/dist/commands/product-offers/get.js +2 -0
- package/dist/commands/product-offers/list.d.ts +1 -0
- package/dist/commands/product-offers/list.js +2 -0
- package/dist/commands/product-offers/update.d.ts +1 -0
- package/dist/commands/product-offers/update.js +2 -0
- package/dist/lib/api-schemas.d.ts +1 -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 +8 -0
- package/dist/sdk/adapty/index.js +6 -0
- package/dist/sdk/adapty/product-offers/create.d.ts +5 -0
- package/dist/sdk/adapty/product-offers/create.js +12 -0
- package/dist/sdk/adapty/product-offers/index.d.ts +9 -0
- package/dist/sdk/adapty/product-offers/index.js +5 -0
- package/dist/sdk/adapty/product-offers/model.d.ts +48 -0
- package/dist/sdk/adapty/product-offers/model.js +30 -0
- package/dist/sdk/adapty/product-offers/resource.d.ts +21 -0
- package/dist/sdk/adapty/product-offers/resource.js +28 -0
- package/dist/sdk/adapty/product-offers/update.d.ts +19 -0
- package/dist/sdk/adapty/product-offers/update.js +42 -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 +1163 -128
- package/package.json +7 -1
|
@@ -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` (a period start as its date, `2026-09-22`), and for a funnel step or a
|
|
41
|
+
* retention period also its title, the number once (`-2 Install`, `1 Paid`, `2 Period`). The CSV keeps the raw `x`.
|
|
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 {};
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
/** A period start as the backend gives it: a date, a time and an offset, as in `2026-09-22T00:00:00.000000+0200`. */
|
|
33
|
+
const TIMESTAMP = /^(\d{4}-\d{2}-\d{2})T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}(?::?\d{2})?)?$/;
|
|
34
|
+
/** The date of a period start in the period's own time zone: the date part as given, with no conversion. */
|
|
35
|
+
const dateOf = (id) => TIMESTAMP.exec(id)?.[1] ?? id;
|
|
36
|
+
/**
|
|
37
|
+
* How the human view names a point: its `x` (a period start as its date, `2026-09-22`), and for a funnel step or a
|
|
38
|
+
* retention period also its title, the number once (`-2 Install`, `1 Paid`, `2 Period`). The CSV keeps the raw `x`.
|
|
39
|
+
*/
|
|
40
|
+
export const pointLabel = (point) => {
|
|
41
|
+
const { title } = point;
|
|
42
|
+
const id = dateOf(pointId(point));
|
|
43
|
+
if (typeof title !== 'string') {
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
return title.startsWith(`${id} `) ? title : `${id} ${title}`;
|
|
47
|
+
};
|
|
48
|
+
/** The same for the CSV, `value` first: the title stays a field, since a step's title can differ between segments. */
|
|
49
|
+
const pointCells = (point) => {
|
|
50
|
+
const fields = fieldsOf(point, ['x']);
|
|
51
|
+
return [...fields.filter(([name]) => name === 'value'), ...fields.filter(([name]) => name !== 'value')];
|
|
52
|
+
};
|
|
53
|
+
const without = (value, names) => Object.fromEntries(Object.entries(value).filter(([name]) => !names.includes(name)));
|
|
54
|
+
/**
|
|
55
|
+
* Every field of the answer besides the segments, under its JSON path: `data.value`, `data.compare.value`,
|
|
56
|
+
* `meta.query.timezone`, `meta.query.filters.date[0]`, `meta.recommended_period.date_from`. The one source of the
|
|
57
|
+
* summary in both views.
|
|
58
|
+
*/
|
|
59
|
+
const answerFields = ({ data, meta }) => {
|
|
60
|
+
const { compare } = data;
|
|
61
|
+
return leaves({
|
|
62
|
+
data: {
|
|
63
|
+
...without(data, ['compare', 'segments']),
|
|
64
|
+
// A chart or a conversion always says whether it has a compared period: null is a value too
|
|
65
|
+
...(compare === undefined ? {} : { compare: compare === null ? null : without(compare, ['segments']) }),
|
|
66
|
+
},
|
|
67
|
+
meta,
|
|
68
|
+
}, '');
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* The summary of the asked period: every field of `data` besides the segments, every field of `meta.query` (the
|
|
72
|
+
* resolved query: the time zone, the filters, the defaults) and every other field of `meta` (a conversion's
|
|
73
|
+
* `recommended_period`), named as in their object. Of a compared period: the fields of `data.compare`.
|
|
74
|
+
*/
|
|
75
|
+
export const sectionsOf = (answer) => {
|
|
76
|
+
const fields = answerFields(answer);
|
|
77
|
+
const { compare, segments } = answer.data;
|
|
78
|
+
const main = fields.filter(([name]) => !name.startsWith('data.compare'))
|
|
79
|
+
.map(([name, value]) => [name.replace(/^(?:data|meta\.query|meta)\./, ''), value]);
|
|
80
|
+
const compared = fields.filter(([name]) => name.startsWith('data.compare.'))
|
|
81
|
+
.map(([name, value]) => [name.slice('data.compare.'.length), value]);
|
|
82
|
+
return [
|
|
83
|
+
{ period: 'main', segments: totalFirst(segments), summary: main },
|
|
84
|
+
...(compare === undefined || compare === null ? [] : [{ period: 'compare', segments: totalFirst(compare.segments), summary: compared }]),
|
|
85
|
+
];
|
|
86
|
+
};
|
|
87
|
+
export const line = ([name, value]) => `${name}: ${formatValue(value)}`;
|
|
88
|
+
/** Names in first-seen order, without a name that only stands for its nested fields elsewhere (a null `predict`). */
|
|
89
|
+
const columnsOf = (rows) => {
|
|
90
|
+
const names = [...new Set(rows.flatMap(fields => fields.map(([name]) => name)))];
|
|
91
|
+
return names.filter(name => !names.some(other => other.startsWith(`${name}.`) || other.startsWith(`${name}[`)));
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* The total is the segment with `is_total`; every other group is found by its raw key, so `null`, `""` and `"None"`
|
|
95
|
+
* are three groups, and none of them is the total. A key can repeat inside one period (a funnel key is the group
|
|
96
|
+
* title): such a key is the same group in both periods only by position, the first with the first, and so on. A key
|
|
97
|
+
* that is unique in both periods is matched by itself.
|
|
98
|
+
*/
|
|
99
|
+
const groupsOf = ({ data }) => {
|
|
100
|
+
const periods = [
|
|
101
|
+
['main', totalFirst(data.segments)],
|
|
102
|
+
['compare', totalFirst(data.compare?.segments ?? [])],
|
|
103
|
+
];
|
|
104
|
+
const keyOf = (segment) => JSON.stringify(segment.key);
|
|
105
|
+
const count = (segments, key) => segments
|
|
106
|
+
.filter(segment => !segment.is_total && keyOf(segment) === key).length;
|
|
107
|
+
const groups = new Map();
|
|
108
|
+
for (const [period, segments] of periods) {
|
|
109
|
+
const seen = new Map();
|
|
110
|
+
for (const segment of segments) {
|
|
111
|
+
const key = keyOf(segment);
|
|
112
|
+
const position = seen.get(key) ?? 0;
|
|
113
|
+
seen.set(key, position + 1);
|
|
114
|
+
const unique = periods.every(([, other]) => count(other, key) <= 1);
|
|
115
|
+
const own = unique ? `key:${key}` : `key:${key}#${position}`;
|
|
116
|
+
const id = segment.is_total ? 'total' : own;
|
|
117
|
+
groups.set(id, { ...groups.get(id), [period]: segment });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const total = groups.get('total');
|
|
121
|
+
const others = [...groups.entries()].filter(([id]) => id !== 'total').map(([, group]) => group);
|
|
122
|
+
return [...(total === undefined ? [] : [total]), ...others];
|
|
123
|
+
};
|
|
124
|
+
/** The columns of one period: the numbers of its segments, then each point's `value` and its other fields. */
|
|
125
|
+
const periodColumns = (segments, prefix, pick) => {
|
|
126
|
+
const own = columnsOf(segments.map(segment => fieldsOf(segment, [...IDENTITY, 'values'])))
|
|
127
|
+
.map(name => ({ header: `${prefix}${name}`, read: (group) => new Map(fieldsOf(pick(group) ?? {}, [])).get(name) }));
|
|
128
|
+
const points = new Map();
|
|
129
|
+
for (const point of segments.flatMap(segment => segment.values)) {
|
|
130
|
+
points.set(pointId(point), [...points.get(pointId(point)) ?? [], pointCells(point)]);
|
|
131
|
+
}
|
|
132
|
+
// Named by `x` alone, the same for every segment; a step's title is a cell of its own, `<x>.title`
|
|
133
|
+
const pointColumns = [...points.entries()].flatMap(([id, fields]) => columnsOf(fields).map(name => ({
|
|
134
|
+
header: `${prefix}${name === 'value' ? id : `${id}.${name}`}`,
|
|
135
|
+
read: (group) => {
|
|
136
|
+
const point = pick(group)?.values.find(item => pointId(item) === id);
|
|
137
|
+
return point === undefined ? undefined : new Map(pointCells(point)).get(name);
|
|
138
|
+
},
|
|
139
|
+
})));
|
|
140
|
+
return [...own, ...pointColumns];
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* The CSV view, made from the API answer: a header line, then one row per group, the total first (`is_total`
|
|
144
|
+
* true; its `key` cell is empty, as is the cell of a group whose raw key is null; a key `""` is written `""`).
|
|
145
|
+
* Columns: `key`, `title`, `is_total`; the numbers of the group in the asked period, then each point's `value`
|
|
146
|
+
* (named by its `x`) and its other fields as `<x>.<field>` (a funnel step's or retention period's title as
|
|
147
|
+
* `<x>.title`); the same for the compared period under `compare.`; then every other field of the answer under its
|
|
148
|
+
* JSON path (`data.value`, `data.compare.value`, `meta.query.timezone`, `meta.query.revenue_basis`, ...), the same
|
|
149
|
+
* on every row. A group of one period only has empty cells in the other. A null value is an empty cell. No groups:
|
|
150
|
+
* the header line alone.
|
|
151
|
+
*/
|
|
152
|
+
export const renderCsv = (answer) => {
|
|
153
|
+
const groups = groupsOf(answer);
|
|
154
|
+
const main = totalFirst(answer.data.segments);
|
|
155
|
+
const compare = totalFirst(answer.data.compare?.segments ?? []);
|
|
156
|
+
const summary = answerFields(answer);
|
|
157
|
+
const summaryNames = columnsOf([summary]);
|
|
158
|
+
const summaryCells = new Map(summary);
|
|
159
|
+
const identity = IDENTITY.map(name => ({
|
|
160
|
+
header: name,
|
|
161
|
+
read: group => new Map(fieldsOf(group.main ?? group.compare ?? {}, [])).get(name),
|
|
162
|
+
}));
|
|
163
|
+
const columns = [
|
|
164
|
+
...identity,
|
|
165
|
+
...(main.length === 0
|
|
166
|
+
? BASE_COLUMNS.slice(IDENTITY.length).map(name => ({ header: name, read: () => undefined }))
|
|
167
|
+
: periodColumns(main, '', group => group.main)),
|
|
168
|
+
...periodColumns(compare, 'compare.', group => group.compare),
|
|
169
|
+
...summaryNames.map(name => ({ header: name, read: () => summaryCells.get(name) })),
|
|
170
|
+
];
|
|
171
|
+
const rows = groups.map(group => columns.map(column => column.read(group)));
|
|
172
|
+
return [columns.map(column => column.header), ...rows].map(cells => csvLine(cells)).join('\n');
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* What a data command writes to stderr: with `--csv`, when the asked period has no segments, `No data for this
|
|
176
|
+
* period.` and the summary of each period, as the human view shows them (the CSV is then its header alone, or the
|
|
177
|
+
* groups of the compared period); else nothing, an empty text.
|
|
178
|
+
*/
|
|
179
|
+
export const csvNotice = (answer, csv) => {
|
|
180
|
+
if (!csv || answer.data.segments.length > 0) {
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
const sections = sectionsOf(answer).map(section => [
|
|
184
|
+
...(section.period === 'compare' ? ['Compared period'] : []),
|
|
185
|
+
...(section.segments.length === 0 ? [NO_DATA] : []),
|
|
186
|
+
...section.summary.map(field => line(field)),
|
|
187
|
+
].join('\n'));
|
|
188
|
+
return `${sections.join('\n\n')}\n`;
|
|
189
|
+
};
|
|
@@ -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';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/product-offers/create.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/product-offers/get.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/product-offers/list.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '../../cli/commands/product-offers/update.js';
|
|
@@ -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
|
+
};
|