@funnelsgrove/cli 0.1.10 → 0.1.12
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 +39 -2
- package/dist/analyticsOutput.d.ts +276 -0
- package/dist/analyticsOutput.js +254 -0
- package/dist/cli.d.ts +31 -1
- package/dist/cli.js +312 -6
- package/dist/localSync.d.ts +1 -0
- package/dist/localSync.js +4 -0
- package/package.json +1 -1
- package/template_docs/AGENTS.md +68 -105
- package/template_docs/docs/ab-experiments.md +40 -20
- package/template_docs/docs/analytics.md +7 -4
- package/template_docs/docs/editing-flow.md +59 -7
- package/template_docs/docs/editing-step.md +105 -46
- package/template_docs/docs/payment-plans-and-discounts.md +11 -0
- package/template_docs/docs/publishing-and-versioning.md +68 -3
- package/template_docs/docs/qa-checklist.md +80 -0
- package/template_docs/docs/step-ui-guidelines.md +110 -0
package/README.md
CHANGED
|
@@ -18,13 +18,27 @@ Common workflow:
|
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
fgrove status
|
|
21
|
+
git status --short
|
|
22
|
+
fgrove github status
|
|
21
23
|
fgrove docs
|
|
24
|
+
# if GitHub is connected: git push, then fgrove github pull
|
|
25
|
+
# if GitHub is not connected:
|
|
22
26
|
fgrove sync up --message 'Update funnel copy'
|
|
23
27
|
fgrove publish --env preview
|
|
24
28
|
```
|
|
25
29
|
|
|
26
30
|
Inside a synced folder, `fgrove` reads `.funnelsgrove-sync.json` first, so you do not need to run `fgrove use` when switching between local funnel directories. Use `fgrove use` only when you want a global fallback context for commands outside a synced folder.
|
|
27
31
|
|
|
32
|
+
`fgrove sync down` refuses to overwrite local changes in an existing synced
|
|
33
|
+
folder unless `--force` is passed. If `fgrove sync up` says the remote draft
|
|
34
|
+
changed since your local sync, download the latest draft into a clean temporary
|
|
35
|
+
directory, merge your local changes, rerun checks, and sync again.
|
|
36
|
+
|
|
37
|
+
`fgrove sync up` is for funnels without GitHub source sync. When GitHub is
|
|
38
|
+
connected, commit and push source changes with normal git, then run `fgrove
|
|
39
|
+
github pull` to sync GitHub into the hosted draft. Do not run `fgrove sync up`
|
|
40
|
+
for the same local diff.
|
|
41
|
+
|
|
28
42
|
Use `fgrove env pull` from a synced folder to refresh only the ignored local `.env` file after project settings change, without replacing source files.
|
|
29
43
|
|
|
30
44
|
GitHub sync workflow:
|
|
@@ -32,12 +46,35 @@ GitHub sync workflow:
|
|
|
32
46
|
```bash
|
|
33
47
|
fgrove github status --funnel claimbee-general
|
|
34
48
|
fgrove github connect --funnel claimbee-general --account The-Solid-Grove --repo claimbee-funnel
|
|
35
|
-
|
|
49
|
+
git push
|
|
36
50
|
fgrove github pull --funnel claimbee-general
|
|
37
51
|
fgrove publish --funnel claimbee-general --env preview
|
|
38
52
|
```
|
|
39
53
|
|
|
40
|
-
The GitHub commands use the FunnelsGrove API only.
|
|
54
|
+
The GitHub commands use the FunnelsGrove API only. `fgrove github pull` pulls
|
|
55
|
+
the repository into the hosted draft after you push normal git commits.
|
|
56
|
+
`fgrove publish` waits for the current draft to reach GitHub before publishing.
|
|
57
|
+
`fgrove github push` still exists for explicit hosted-draft-to-GitHub recovery
|
|
58
|
+
work, but it is not the normal path for local source changes. Local `.env*`
|
|
59
|
+
files remain CLI-local runtime material from `sync down`; they are not sent to
|
|
60
|
+
GitHub sync.
|
|
61
|
+
|
|
62
|
+
Analytics workflow:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11
|
|
66
|
+
fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11
|
|
67
|
+
fgrove analytics transitions --project claimbee --funnel claimbee-ios --date 2026-06-11
|
|
68
|
+
fgrove analytics cohort --project claimbee --date 2026-06-11
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Each analytics command accepts `--format table|json`, `--out <path>`, `--workspace`, and `--timezone`. Use JSON for agents and automations:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json --out conversions.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`conversions` downloads the one-day conversion totals, primary conversion metrics, full funnel path rows, and step transitions. `funnel-path` focuses on the ordered path report. `transitions` focuses on step-by-step advanced/drop-off counts. `cohort` downloads synced marketing cohort economics for the day. If the requested day has no synced data, or cohort source data is incomplete, the CLI exits non-zero with a human-readable explanation.
|
|
41
78
|
|
|
42
79
|
The package also keeps the longer `funnelsgrove` command as a compatibility alias.
|
|
43
80
|
Use `--api-url` or `FUNNELSGROVE_API_URL` for non-production APIs.
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
export type AnalyticsOutputFormat = 'table' | 'json';
|
|
2
|
+
export type AnalyticsProjectSummary = {
|
|
3
|
+
id: string;
|
|
4
|
+
name?: string | null;
|
|
5
|
+
slug?: string | null;
|
|
6
|
+
};
|
|
7
|
+
export type AnalyticsFunnelSummary = {
|
|
8
|
+
id: string;
|
|
9
|
+
name?: string | null;
|
|
10
|
+
slug?: string | null;
|
|
11
|
+
};
|
|
12
|
+
export type AnalyticsOverviewResponse = {
|
|
13
|
+
generatedAt: string;
|
|
14
|
+
from: string | null;
|
|
15
|
+
to: string | null;
|
|
16
|
+
currency: string | null;
|
|
17
|
+
funnels: AnalyticsFunnelSummary[];
|
|
18
|
+
totals: Record<string, number>;
|
|
19
|
+
byDay: Array<{
|
|
20
|
+
date: string;
|
|
21
|
+
visitors: number;
|
|
22
|
+
subscriptions: number;
|
|
23
|
+
chargedRevenueMinor: number;
|
|
24
|
+
}>;
|
|
25
|
+
};
|
|
26
|
+
export type AnalyticsStepDropoffResponse = {
|
|
27
|
+
generatedAt: string;
|
|
28
|
+
from: string | null;
|
|
29
|
+
to: string | null;
|
|
30
|
+
funnel: {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
slug: string;
|
|
34
|
+
};
|
|
35
|
+
version: {
|
|
36
|
+
selected: {
|
|
37
|
+
id: string;
|
|
38
|
+
seq: number;
|
|
39
|
+
message: string | null;
|
|
40
|
+
publishedAt: string | null;
|
|
41
|
+
isCurrent: boolean;
|
|
42
|
+
} | null;
|
|
43
|
+
options: Array<{
|
|
44
|
+
id: string;
|
|
45
|
+
seq: number;
|
|
46
|
+
message: string | null;
|
|
47
|
+
publishedAt: string | null;
|
|
48
|
+
isCurrent: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
totals: Record<string, number>;
|
|
52
|
+
metrics: Array<{
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
numerator: number;
|
|
56
|
+
denominator: number;
|
|
57
|
+
rate: number;
|
|
58
|
+
numeratorLabel: string;
|
|
59
|
+
denominatorLabel: string;
|
|
60
|
+
description: string;
|
|
61
|
+
chartPoints: unknown[];
|
|
62
|
+
}>;
|
|
63
|
+
steps: AnalyticsStepTransition[];
|
|
64
|
+
flow: {
|
|
65
|
+
steps: Array<{
|
|
66
|
+
stepId: string;
|
|
67
|
+
stepName: string | null;
|
|
68
|
+
stepPath: string | null;
|
|
69
|
+
stepTitle: string | null;
|
|
70
|
+
stepType: string | null;
|
|
71
|
+
stepKind: string | null;
|
|
72
|
+
stepOrder: number;
|
|
73
|
+
}>;
|
|
74
|
+
experiments: unknown[];
|
|
75
|
+
branches: unknown[];
|
|
76
|
+
} | null;
|
|
77
|
+
};
|
|
78
|
+
export type AnalyticsStepTransition = {
|
|
79
|
+
stepId: string;
|
|
80
|
+
stepName: string | null;
|
|
81
|
+
stepOrder: number;
|
|
82
|
+
entrants: number;
|
|
83
|
+
completions: number;
|
|
84
|
+
advancedToNextStep: number;
|
|
85
|
+
dropoffs: number;
|
|
86
|
+
dropoffRate: number;
|
|
87
|
+
nextStepId: string | null;
|
|
88
|
+
nextStepName: string | null;
|
|
89
|
+
};
|
|
90
|
+
export type MarketingCohortPerformanceRow = {
|
|
91
|
+
id: string;
|
|
92
|
+
cohortDate: string;
|
|
93
|
+
funnelUrl: string;
|
|
94
|
+
mediaSource: string;
|
|
95
|
+
spend: number;
|
|
96
|
+
subscribers: number;
|
|
97
|
+
subscribersAlive: number;
|
|
98
|
+
subscribersAlivePercent: number | null;
|
|
99
|
+
revenue: number;
|
|
100
|
+
pLtv: number | null;
|
|
101
|
+
pLtvPredictedNetRevenueDay365Minor: number;
|
|
102
|
+
pLtvPredictedCustomerCount: number;
|
|
103
|
+
campaign: string;
|
|
104
|
+
channel: string;
|
|
105
|
+
country: string;
|
|
106
|
+
children?: MarketingCohortPerformanceRow[];
|
|
107
|
+
segments?: MarketingCohortPerformanceRow[];
|
|
108
|
+
};
|
|
109
|
+
export type MarketingCohortPerformanceReport = {
|
|
110
|
+
generatedAt: string;
|
|
111
|
+
from: string | null;
|
|
112
|
+
to: string | null;
|
|
113
|
+
rows: MarketingCohortPerformanceRow[];
|
|
114
|
+
sourceHealth?: {
|
|
115
|
+
complete: boolean;
|
|
116
|
+
warnings: string[];
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
export type ParsedAnalyticsDate = {
|
|
120
|
+
date: string;
|
|
121
|
+
fromIso: string;
|
|
122
|
+
toIso: string;
|
|
123
|
+
};
|
|
124
|
+
type ReportContext = {
|
|
125
|
+
date: string;
|
|
126
|
+
workspaceId: string;
|
|
127
|
+
project: AnalyticsProjectSummary;
|
|
128
|
+
};
|
|
129
|
+
export declare const parseAnalyticsDate: (value: string) => ParsedAnalyticsDate;
|
|
130
|
+
export declare const normalizeAnalyticsOutputFormat: (value: string | undefined) => AnalyticsOutputFormat;
|
|
131
|
+
export declare const assertHasConversionData: (date: string, overview: AnalyticsOverviewResponse, stepDropoff: AnalyticsStepDropoffResponse) => void;
|
|
132
|
+
export declare const assertHasTransitionData: (date: string, stepDropoff: AnalyticsStepDropoffResponse) => void;
|
|
133
|
+
export declare const assertHasFunnelPathData: (date: string, stepDropoff: AnalyticsStepDropoffResponse) => void;
|
|
134
|
+
export declare const assertHasCohortData: (date: string, report: MarketingCohortPerformanceReport) => void;
|
|
135
|
+
export declare const buildTransitionRows: (stepDropoff: AnalyticsStepDropoffResponse) => {
|
|
136
|
+
stepId: string;
|
|
137
|
+
step: string;
|
|
138
|
+
nextStep: string;
|
|
139
|
+
entrants: number;
|
|
140
|
+
advancedToNextStep: number;
|
|
141
|
+
dropoffs: number;
|
|
142
|
+
dropoffRate: number;
|
|
143
|
+
}[];
|
|
144
|
+
export declare const buildFunnelPathRows: (stepDropoff: AnalyticsStepDropoffResponse) => {
|
|
145
|
+
stepId: string;
|
|
146
|
+
path: string;
|
|
147
|
+
label: string;
|
|
148
|
+
users: number;
|
|
149
|
+
advancedToNextStep: number;
|
|
150
|
+
conversionToNextStep: number;
|
|
151
|
+
dropoffs: number;
|
|
152
|
+
dropoffRate: number;
|
|
153
|
+
}[];
|
|
154
|
+
export declare const buildConversionReportPayload: (input: ReportContext & {
|
|
155
|
+
overview: AnalyticsOverviewResponse;
|
|
156
|
+
stepDropoff: AnalyticsStepDropoffResponse;
|
|
157
|
+
}) => {
|
|
158
|
+
generatedAt: string;
|
|
159
|
+
date: string;
|
|
160
|
+
workspaceId: string;
|
|
161
|
+
project: AnalyticsProjectSummary;
|
|
162
|
+
funnel: {
|
|
163
|
+
id: string;
|
|
164
|
+
name: string;
|
|
165
|
+
slug: string;
|
|
166
|
+
};
|
|
167
|
+
overview: AnalyticsOverviewResponse;
|
|
168
|
+
totals: Record<string, number>;
|
|
169
|
+
metrics: {
|
|
170
|
+
id: string;
|
|
171
|
+
label: string;
|
|
172
|
+
numerator: number;
|
|
173
|
+
denominator: number;
|
|
174
|
+
rate: number;
|
|
175
|
+
numeratorLabel: string;
|
|
176
|
+
denominatorLabel: string;
|
|
177
|
+
description: string;
|
|
178
|
+
chartPoints: unknown[];
|
|
179
|
+
}[];
|
|
180
|
+
funnelPath: {
|
|
181
|
+
stepId: string;
|
|
182
|
+
path: string;
|
|
183
|
+
label: string;
|
|
184
|
+
users: number;
|
|
185
|
+
advancedToNextStep: number;
|
|
186
|
+
conversionToNextStep: number;
|
|
187
|
+
dropoffs: number;
|
|
188
|
+
dropoffRate: number;
|
|
189
|
+
}[];
|
|
190
|
+
transitions: {
|
|
191
|
+
stepId: string;
|
|
192
|
+
step: string;
|
|
193
|
+
nextStep: string;
|
|
194
|
+
entrants: number;
|
|
195
|
+
advancedToNextStep: number;
|
|
196
|
+
dropoffs: number;
|
|
197
|
+
dropoffRate: number;
|
|
198
|
+
}[];
|
|
199
|
+
};
|
|
200
|
+
export declare const buildFunnelPathReportPayload: (input: ReportContext & {
|
|
201
|
+
stepDropoff: AnalyticsStepDropoffResponse;
|
|
202
|
+
}) => {
|
|
203
|
+
generatedAt: string;
|
|
204
|
+
date: string;
|
|
205
|
+
workspaceId: string;
|
|
206
|
+
project: AnalyticsProjectSummary;
|
|
207
|
+
funnel: {
|
|
208
|
+
id: string;
|
|
209
|
+
name: string;
|
|
210
|
+
slug: string;
|
|
211
|
+
};
|
|
212
|
+
funnelPath: {
|
|
213
|
+
stepId: string;
|
|
214
|
+
path: string;
|
|
215
|
+
label: string;
|
|
216
|
+
users: number;
|
|
217
|
+
advancedToNextStep: number;
|
|
218
|
+
conversionToNextStep: number;
|
|
219
|
+
dropoffs: number;
|
|
220
|
+
dropoffRate: number;
|
|
221
|
+
}[];
|
|
222
|
+
};
|
|
223
|
+
export declare const buildTransitionReportPayload: (input: ReportContext & {
|
|
224
|
+
stepDropoff: AnalyticsStepDropoffResponse;
|
|
225
|
+
}) => {
|
|
226
|
+
generatedAt: string;
|
|
227
|
+
date: string;
|
|
228
|
+
workspaceId: string;
|
|
229
|
+
project: AnalyticsProjectSummary;
|
|
230
|
+
funnel: {
|
|
231
|
+
id: string;
|
|
232
|
+
name: string;
|
|
233
|
+
slug: string;
|
|
234
|
+
};
|
|
235
|
+
transitions: {
|
|
236
|
+
stepId: string;
|
|
237
|
+
step: string;
|
|
238
|
+
nextStep: string;
|
|
239
|
+
entrants: number;
|
|
240
|
+
advancedToNextStep: number;
|
|
241
|
+
dropoffs: number;
|
|
242
|
+
dropoffRate: number;
|
|
243
|
+
}[];
|
|
244
|
+
};
|
|
245
|
+
export declare const buildCohortReportPayload: (input: ReportContext & {
|
|
246
|
+
cohort: MarketingCohortPerformanceReport;
|
|
247
|
+
}) => {
|
|
248
|
+
generatedAt: string;
|
|
249
|
+
date: string;
|
|
250
|
+
workspaceId: string;
|
|
251
|
+
project: AnalyticsProjectSummary;
|
|
252
|
+
cohort: MarketingCohortPerformanceReport;
|
|
253
|
+
sourceHealth: {
|
|
254
|
+
complete: boolean;
|
|
255
|
+
warnings: string[];
|
|
256
|
+
} | null;
|
|
257
|
+
};
|
|
258
|
+
export declare const formatAnalyticsJson: (payload: unknown) => string;
|
|
259
|
+
export declare const formatTransitionsTable: (input: {
|
|
260
|
+
date: string;
|
|
261
|
+
stepDropoff: AnalyticsStepDropoffResponse;
|
|
262
|
+
}) => string;
|
|
263
|
+
export declare const formatFunnelPathTable: (input: {
|
|
264
|
+
date: string;
|
|
265
|
+
stepDropoff: AnalyticsStepDropoffResponse;
|
|
266
|
+
}) => string;
|
|
267
|
+
export declare const formatConversionsTable: (input: {
|
|
268
|
+
date: string;
|
|
269
|
+
overview: AnalyticsOverviewResponse;
|
|
270
|
+
stepDropoff: AnalyticsStepDropoffResponse;
|
|
271
|
+
}) => string;
|
|
272
|
+
export declare const formatCohortTable: (input: {
|
|
273
|
+
date: string;
|
|
274
|
+
cohort: MarketingCohortPerformanceReport;
|
|
275
|
+
}) => string;
|
|
276
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
export const parseAnalyticsDate = (value) => {
|
|
2
|
+
const trimmed = value.trim();
|
|
3
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
|
|
4
|
+
if (!match) {
|
|
5
|
+
throw new Error('Expected --date in YYYY-MM-DD format.');
|
|
6
|
+
}
|
|
7
|
+
const year = Number(match[1]);
|
|
8
|
+
const month = Number(match[2]);
|
|
9
|
+
const day = Number(match[3]);
|
|
10
|
+
const timestamp = Date.UTC(year, month - 1, day);
|
|
11
|
+
const parsed = new Date(timestamp);
|
|
12
|
+
const isRealDate = parsed.getUTCFullYear() === year &&
|
|
13
|
+
parsed.getUTCMonth() + 1 === month &&
|
|
14
|
+
parsed.getUTCDate() === day;
|
|
15
|
+
if (!isRealDate) {
|
|
16
|
+
throw new Error('Expected --date to be a real calendar date.');
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
date: trimmed,
|
|
20
|
+
fromIso: `${trimmed}T00:00:00.000Z`,
|
|
21
|
+
toIso: `${trimmed}T23:59:59.999Z`,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export const normalizeAnalyticsOutputFormat = (value) => {
|
|
25
|
+
const normalized = (value || 'table').trim().toLowerCase();
|
|
26
|
+
if (normalized === 'table' || normalized === 'json') {
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
throw new Error('--format must be table or json.');
|
|
30
|
+
};
|
|
31
|
+
const hasPositiveNumber = (value) => {
|
|
32
|
+
if (typeof value === 'number') {
|
|
33
|
+
return Number.isFinite(value) && value > 0;
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
return value.some(hasPositiveNumber);
|
|
37
|
+
}
|
|
38
|
+
if (value && typeof value === 'object') {
|
|
39
|
+
return Object.values(value).some(hasPositiveNumber);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
export const assertHasConversionData = (date, overview, stepDropoff) => {
|
|
44
|
+
if (hasPositiveNumber(overview.totals) ||
|
|
45
|
+
hasPositiveNumber(overview.byDay) ||
|
|
46
|
+
hasPositiveNumber(stepDropoff.totals) ||
|
|
47
|
+
hasPositiveNumber(stepDropoff.steps)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`No synced conversion data found for ${date}. Analytics may not have synced yet, or the funnel had no tracked traffic that day.`);
|
|
51
|
+
};
|
|
52
|
+
export const assertHasTransitionData = (date, stepDropoff) => {
|
|
53
|
+
if (stepDropoff.steps.length > 0 && hasPositiveNumber(stepDropoff.steps)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`No synced step transition data found for ${date}. Analytics may not have synced yet, or the funnel had no tracked step traffic that day.`);
|
|
57
|
+
};
|
|
58
|
+
export const assertHasFunnelPathData = (date, stepDropoff) => {
|
|
59
|
+
if (buildFunnelPathRows(stepDropoff).length > 0 && hasPositiveNumber(stepDropoff.steps)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`No synced full funnel path data found for ${date}. Analytics may not have synced yet, or the funnel had no tracked path traffic that day.`);
|
|
63
|
+
};
|
|
64
|
+
export const assertHasCohortData = (date, report) => {
|
|
65
|
+
if (report.sourceHealth && !report.sourceHealth.complete) {
|
|
66
|
+
const warnings = report.sourceHealth.warnings.join(' ').trim();
|
|
67
|
+
throw new Error(`Cohort source data for ${date} is not fully synced. ${warnings || 'Required marketing sources have not finished syncing.'}`);
|
|
68
|
+
}
|
|
69
|
+
if (report.rows.length > 0) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`No synced cohort data found for ${date}. Marketing cohort data may not have synced yet, or there were no subscribers for that day.`);
|
|
73
|
+
};
|
|
74
|
+
const formatNumber = (value) => Math.round(value).toLocaleString('en-US');
|
|
75
|
+
const formatPercent = (value) => (typeof value === 'number' && Number.isFinite(value)
|
|
76
|
+
? `${(value * 100).toFixed(2)}%`
|
|
77
|
+
: '');
|
|
78
|
+
const formatPercentPoints = (value) => (typeof value === 'number' && Number.isFinite(value)
|
|
79
|
+
? `${value.toFixed(2)}%`
|
|
80
|
+
: '');
|
|
81
|
+
const formatCurrency = (value) => new Intl.NumberFormat('en-US', {
|
|
82
|
+
currency: 'USD',
|
|
83
|
+
maximumFractionDigits: 2,
|
|
84
|
+
style: 'currency',
|
|
85
|
+
}).format(value);
|
|
86
|
+
const formatMinorCurrency = (value) => formatCurrency((Number(value) || 0) / 100);
|
|
87
|
+
const stepDisplayName = (step) => step.stepName || step.stepId;
|
|
88
|
+
const flowStepDisplay = (stepDropoff, step) => {
|
|
89
|
+
const flowStep = stepDropoff.flow?.steps.find((candidate) => candidate.stepId === step.stepId);
|
|
90
|
+
return {
|
|
91
|
+
label: flowStep?.stepTitle || step.stepName || flowStep?.stepName || step.stepId,
|
|
92
|
+
path: flowStep?.stepPath || step.stepId,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
export const buildTransitionRows = (stepDropoff) => [...stepDropoff.steps]
|
|
96
|
+
.sort((left, right) => left.stepOrder - right.stepOrder || left.stepId.localeCompare(right.stepId))
|
|
97
|
+
.map((step) => ({
|
|
98
|
+
stepId: step.stepId,
|
|
99
|
+
step: stepDisplayName(step),
|
|
100
|
+
nextStep: step.nextStepId || '',
|
|
101
|
+
entrants: step.entrants,
|
|
102
|
+
advancedToNextStep: step.advancedToNextStep,
|
|
103
|
+
dropoffs: step.dropoffs,
|
|
104
|
+
dropoffRate: step.dropoffRate,
|
|
105
|
+
}));
|
|
106
|
+
export const buildFunnelPathRows = (stepDropoff) => buildTransitionRows(stepDropoff).map((row) => {
|
|
107
|
+
const step = stepDropoff.steps.find((candidate) => candidate.stepId === row.stepId);
|
|
108
|
+
const display = flowStepDisplay(stepDropoff, step);
|
|
109
|
+
return {
|
|
110
|
+
stepId: row.stepId,
|
|
111
|
+
path: display.path,
|
|
112
|
+
label: display.label,
|
|
113
|
+
users: row.entrants,
|
|
114
|
+
advancedToNextStep: row.advancedToNextStep,
|
|
115
|
+
conversionToNextStep: row.entrants > 0 ? row.advancedToNextStep / row.entrants : 0,
|
|
116
|
+
dropoffs: row.dropoffs,
|
|
117
|
+
dropoffRate: row.dropoffRate,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
export const buildConversionReportPayload = (input) => ({
|
|
121
|
+
generatedAt: new Date().toISOString(),
|
|
122
|
+
date: input.date,
|
|
123
|
+
workspaceId: input.workspaceId,
|
|
124
|
+
project: input.project,
|
|
125
|
+
funnel: input.stepDropoff.funnel,
|
|
126
|
+
overview: input.overview,
|
|
127
|
+
totals: input.stepDropoff.totals,
|
|
128
|
+
metrics: input.stepDropoff.metrics,
|
|
129
|
+
funnelPath: buildFunnelPathRows(input.stepDropoff),
|
|
130
|
+
transitions: buildTransitionRows(input.stepDropoff),
|
|
131
|
+
});
|
|
132
|
+
export const buildFunnelPathReportPayload = (input) => ({
|
|
133
|
+
generatedAt: new Date().toISOString(),
|
|
134
|
+
date: input.date,
|
|
135
|
+
workspaceId: input.workspaceId,
|
|
136
|
+
project: input.project,
|
|
137
|
+
funnel: input.stepDropoff.funnel,
|
|
138
|
+
funnelPath: buildFunnelPathRows(input.stepDropoff),
|
|
139
|
+
});
|
|
140
|
+
export const buildTransitionReportPayload = (input) => ({
|
|
141
|
+
generatedAt: new Date().toISOString(),
|
|
142
|
+
date: input.date,
|
|
143
|
+
workspaceId: input.workspaceId,
|
|
144
|
+
project: input.project,
|
|
145
|
+
funnel: input.stepDropoff.funnel,
|
|
146
|
+
transitions: buildTransitionRows(input.stepDropoff),
|
|
147
|
+
});
|
|
148
|
+
export const buildCohortReportPayload = (input) => ({
|
|
149
|
+
generatedAt: new Date().toISOString(),
|
|
150
|
+
date: input.date,
|
|
151
|
+
workspaceId: input.workspaceId,
|
|
152
|
+
project: input.project,
|
|
153
|
+
cohort: input.cohort,
|
|
154
|
+
sourceHealth: input.cohort.sourceHealth || null,
|
|
155
|
+
});
|
|
156
|
+
export const formatAnalyticsJson = (payload) => `${JSON.stringify(payload, null, 2)}\n`;
|
|
157
|
+
export const formatTransitionsTable = (input) => {
|
|
158
|
+
const lines = [
|
|
159
|
+
`Step transitions\t${input.date}\t${input.stepDropoff.funnel.name}`,
|
|
160
|
+
'step\tnextStep\tentrants\tadvanced\tdropoffs\tdropoffRate',
|
|
161
|
+
];
|
|
162
|
+
for (const row of buildTransitionRows(input.stepDropoff)) {
|
|
163
|
+
lines.push([
|
|
164
|
+
row.step,
|
|
165
|
+
row.nextStep,
|
|
166
|
+
formatNumber(row.entrants),
|
|
167
|
+
formatNumber(row.advancedToNextStep),
|
|
168
|
+
formatNumber(row.dropoffs),
|
|
169
|
+
formatPercent(row.dropoffRate),
|
|
170
|
+
].join('\t'));
|
|
171
|
+
}
|
|
172
|
+
return `${lines.join('\n')}\n`;
|
|
173
|
+
};
|
|
174
|
+
export const formatFunnelPathTable = (input) => {
|
|
175
|
+
const lines = [
|
|
176
|
+
`Full funnel path\t${input.date}\t${input.stepDropoff.funnel.name}`,
|
|
177
|
+
'step\tpath\tusers\tadvanced\tconversionToNext\tdropoffs\tdropoffRate',
|
|
178
|
+
];
|
|
179
|
+
for (const row of buildFunnelPathRows(input.stepDropoff)) {
|
|
180
|
+
lines.push([
|
|
181
|
+
row.label,
|
|
182
|
+
row.path,
|
|
183
|
+
formatNumber(row.users),
|
|
184
|
+
formatNumber(row.advancedToNextStep),
|
|
185
|
+
formatPercent(row.conversionToNextStep),
|
|
186
|
+
formatNumber(row.dropoffs),
|
|
187
|
+
formatPercent(row.dropoffRate),
|
|
188
|
+
].join('\t'));
|
|
189
|
+
}
|
|
190
|
+
return `${lines.join('\n')}\n`;
|
|
191
|
+
};
|
|
192
|
+
export const formatConversionsTable = (input) => {
|
|
193
|
+
const totals = input.stepDropoff.totals;
|
|
194
|
+
const overviewTotals = input.overview.totals;
|
|
195
|
+
const lines = [
|
|
196
|
+
`Conversions\t${input.date}\t${input.stepDropoff.funnel.name}`,
|
|
197
|
+
'metric\tvalue',
|
|
198
|
+
['visitors', formatNumber(totals.visitors ?? overviewTotals.visitors ?? 0)].join('\t'),
|
|
199
|
+
['quizEntries', formatNumber(totals.quizEntries ?? 0)].join('\t'),
|
|
200
|
+
['quizStarts', formatNumber(totals.quizStarts ?? overviewTotals.quizStarts ?? 0)].join('\t'),
|
|
201
|
+
['finishedQuiz', formatNumber(totals.finishedQuiz ?? overviewTotals.finishedQuiz ?? 0)].join('\t'),
|
|
202
|
+
['emailAdds', formatNumber(totals.emailAdds ?? overviewTotals.emailAdds ?? 0)].join('\t'),
|
|
203
|
+
['paywallViews', formatNumber(totals.paywallViews ?? 0)].join('\t'),
|
|
204
|
+
['checkoutStarts', formatNumber(totals.checkoutStarts ?? overviewTotals.checkoutStarts ?? 0)].join('\t'),
|
|
205
|
+
['subscriptions', formatNumber(totals.subscriptions ?? overviewTotals.subscriptionsStarted ?? 0)].join('\t'),
|
|
206
|
+
['completedRegistrations', formatNumber(totals.completedRegistrations ?? overviewTotals.completedRegistrations ?? 0)].join('\t'),
|
|
207
|
+
['netRevenue', formatMinorCurrency(overviewTotals.netRevenueMinor)].join('\t'),
|
|
208
|
+
'',
|
|
209
|
+
'conversionMetric\tnumerator\tdenominator\trate',
|
|
210
|
+
];
|
|
211
|
+
for (const metric of input.stepDropoff.metrics) {
|
|
212
|
+
lines.push([
|
|
213
|
+
metric.label,
|
|
214
|
+
formatNumber(metric.numerator),
|
|
215
|
+
formatNumber(metric.denominator),
|
|
216
|
+
formatPercent(metric.rate),
|
|
217
|
+
].join('\t'));
|
|
218
|
+
}
|
|
219
|
+
lines.push('', formatFunnelPathTable({
|
|
220
|
+
date: input.date,
|
|
221
|
+
stepDropoff: input.stepDropoff,
|
|
222
|
+
}).trimEnd());
|
|
223
|
+
return `${lines.join('\n')}\n`;
|
|
224
|
+
};
|
|
225
|
+
const flattenCohortRows = (rows) => rows.flatMap((row) => [row, ...(row.segments || []), ...(row.children || [])]);
|
|
226
|
+
const predictedAverageLtv = (row) => {
|
|
227
|
+
if (row.pLtvPredictedCustomerCount > 0) {
|
|
228
|
+
return row.pLtvPredictedNetRevenueDay365Minor / row.pLtvPredictedCustomerCount / 100;
|
|
229
|
+
}
|
|
230
|
+
return Number(row.pLtv) || 0;
|
|
231
|
+
};
|
|
232
|
+
export const formatCohortTable = (input) => {
|
|
233
|
+
const lines = [
|
|
234
|
+
`Cohort\t${input.date}`,
|
|
235
|
+
'cohort\tmediaSource\tfunnelUrl\tspend\tsubscribers\taliveRate\trevenue\tpLtv\tcpa\tpredictedProfit',
|
|
236
|
+
];
|
|
237
|
+
for (const row of flattenCohortRows(input.cohort.rows)) {
|
|
238
|
+
const cpa = row.subscribers > 0 ? row.spend / row.subscribers : 0;
|
|
239
|
+
const predictedRevenue = predictedAverageLtv(row) * row.subscribers;
|
|
240
|
+
lines.push([
|
|
241
|
+
row.cohortDate,
|
|
242
|
+
row.mediaSource,
|
|
243
|
+
row.funnelUrl,
|
|
244
|
+
formatCurrency(row.spend),
|
|
245
|
+
formatNumber(row.subscribers),
|
|
246
|
+
formatPercentPoints(row.subscribersAlivePercent),
|
|
247
|
+
formatCurrency(row.revenue),
|
|
248
|
+
row.pLtv === null ? '' : formatCurrency(row.pLtv),
|
|
249
|
+
row.subscribers > 0 ? formatCurrency(cpa) : '',
|
|
250
|
+
formatCurrency(predictedRevenue - row.spend),
|
|
251
|
+
].join('\t'));
|
|
252
|
+
}
|
|
253
|
+
return `${lines.join('\n')}\n`;
|
|
254
|
+
};
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type ActiveContext } from './authStore.js';
|
|
3
|
-
import { type SyncManifest } from './localSync.js';
|
|
3
|
+
import { type SourceFile, type SyncManifest } from './localSync.js';
|
|
4
|
+
import { type GitHubStatusResponse } from './githubOutput.js';
|
|
5
|
+
import { type AnalyticsOutputFormat } from './analyticsOutput.js';
|
|
6
|
+
type AnalyticsCommandInput = {
|
|
7
|
+
date: string;
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
format: AnalyticsOutputFormat;
|
|
11
|
+
timezone?: string;
|
|
12
|
+
};
|
|
4
13
|
type SyncTargetIdInput = {
|
|
5
14
|
explicitWorkspaceId?: string;
|
|
6
15
|
explicitFunnelId?: string;
|
|
@@ -13,4 +22,25 @@ export declare function resolveSyncTargetIds(input: SyncTargetIdInput): {
|
|
|
13
22
|
funnelId?: string;
|
|
14
23
|
};
|
|
15
24
|
export declare function isCliEntrypoint(invokedPath: string | undefined, modulePath: string, realpath?: (filePath: string) => string): boolean;
|
|
25
|
+
export declare function buildAnalyticsCommandInput(options: {
|
|
26
|
+
date: string;
|
|
27
|
+
format?: string;
|
|
28
|
+
timezone?: string;
|
|
29
|
+
}): AnalyticsCommandInput;
|
|
30
|
+
export declare function buildPatchSourceInput(input: {
|
|
31
|
+
workspaceId: string;
|
|
32
|
+
funnelId: string;
|
|
33
|
+
message?: string;
|
|
34
|
+
baseDraftVersionId?: string;
|
|
35
|
+
files: SourceFile[];
|
|
36
|
+
deletedPaths: string[];
|
|
37
|
+
}): {
|
|
38
|
+
workspaceId: string;
|
|
39
|
+
funnelId: string;
|
|
40
|
+
message?: string;
|
|
41
|
+
baseDraftVersionId?: string;
|
|
42
|
+
files: SourceFile[];
|
|
43
|
+
deletedPaths: string[];
|
|
44
|
+
};
|
|
45
|
+
export declare function assertCanDraftSyncSource(status: GitHubStatusResponse): void;
|
|
16
46
|
export {};
|