@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 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
- fgrove github push --funnel claimbee-general
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. When GitHub is connected, `fgrove sync up` pushes the resulting draft to GitHub before returning, and `fgrove publish` waits for the current draft to reach GitHub before publishing. Local `.env*` files remain CLI-local runtime material from `sync down`; they are not sent to GitHub sync.
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 {};