@supalytics/cli 0.4.1 → 0.4.3

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.
@@ -0,0 +1,485 @@
1
+ import chalk from "chalk"
2
+ import { Command } from "commander"
3
+ import {
4
+ listFunnels,
5
+ getFunnel,
6
+ createFunnel,
7
+ updateFunnel,
8
+ analyzeFunnel,
9
+ formatNumber,
10
+ formatPercent,
11
+ type FunnelStepInput,
12
+ type FunnelAnalysisRow,
13
+ } from "../api"
14
+ import { getDefaultSite } from "../config"
15
+ import { parsePeriod, sparkBar } from "../ui"
16
+
17
+ /**
18
+ * Merge subcommand options with parent command options.
19
+ * Commander doesn't propagate options like -s/--site and --json
20
+ * from parent to subcommands, so we check both.
21
+ */
22
+ function mergedOpts(cmd: Command): Record<string, unknown> {
23
+ const parentOpts = cmd.parent?.opts() || {}
24
+ const ownOpts = cmd.opts()
25
+ // Own opts take priority, but fall back to parent for unset values
26
+ return { ...parentOpts, ...ownOpts }
27
+ }
28
+
29
+ async function resolveSiteOption(cmd: Command): Promise<string | undefined> {
30
+ const opts = mergedOpts(cmd)
31
+ return (opts.site as string) || (await getDefaultSite())
32
+ }
33
+
34
+ const funnelsExamples = `
35
+ Examples:
36
+ # List all funnels
37
+ supalytics funnels
38
+
39
+ # View a funnel with conversion analysis
40
+ supalytics funnels view <id>
41
+
42
+ # View with custom date range
43
+ supalytics funnels view <id> -p 90d
44
+
45
+ # Create a funnel
46
+ supalytics funnels create "Signup Flow" --step "page:/pricing" --step "event:signup_clicked" --step "purchase"
47
+
48
+ # Create with page matching
49
+ supalytics funnels create "Blog to Signup" --step "page:starts_with:/blog" --step "page:/signup" --step "event:account_created"
50
+
51
+ # Create an ordered funnel
52
+ supalytics funnels create "Checkout" --mode ordered --step "page:/cart" --step "page:/checkout" --step "purchase"
53
+
54
+ # Update funnel name
55
+ supalytics funnels update <id> --name "New Name"
56
+
57
+ # Update funnel steps
58
+ supalytics funnels update <id> --step "page:/pricing" --step "purchase"`
59
+
60
+ /**
61
+ * Parse a step definition string into a FunnelStepInput.
62
+ *
63
+ * Formats:
64
+ * page:/pricing → page, exact, /pricing
65
+ * page:starts_with:/blog → page, starts_with, /blog
66
+ * page:contains:pricing → page, contains, pricing
67
+ * page:regex:^/blog/.* → page, regex, ^/blog/.*
68
+ * event:signup_clicked → event, match_value=signup_clicked
69
+ * event:signup:plan:pro → event, match_value=signup, property_key=plan, property_value=pro
70
+ * purchase → purchase type
71
+ * trial → trial type
72
+ */
73
+ function parseStep(definition: string): FunnelStepInput {
74
+ const parts = definition.split(":")
75
+
76
+ // Simple types: "purchase" or "trial"
77
+ if (parts.length === 1) {
78
+ const type = parts[0].toLowerCase()
79
+ if (type === "purchase" || type === "trial") {
80
+ return { name: type === "purchase" ? "Purchase" : "Trial start", type }
81
+ }
82
+ throw new Error(
83
+ `Invalid step: "${definition}". Use "page:<path>", "event:<name>", "purchase", or "trial"`
84
+ )
85
+ }
86
+
87
+ const type = parts[0].toLowerCase()
88
+
89
+ if (type === "page") {
90
+ // Check for explicit match type: page:starts_with:/blog
91
+ const matchTypes = ["exact", "starts_with", "contains", "regex"]
92
+ if (parts.length >= 3 && matchTypes.includes(parts[1])) {
93
+ const matchType = parts[1]
94
+ const matchValue = parts.slice(2).join(":")
95
+ return {
96
+ name: `Visit ${matchValue}`,
97
+ type: "page",
98
+ match_type: matchType,
99
+ match_value: matchValue,
100
+ }
101
+ }
102
+ // Default: page:/pricing → exact match
103
+ const matchValue = parts.slice(1).join(":")
104
+ return {
105
+ name: `Visit ${matchValue}`,
106
+ type: "page",
107
+ match_type: "exact",
108
+ match_value: matchValue,
109
+ }
110
+ }
111
+
112
+ if (type === "event") {
113
+ const eventName = parts[1]
114
+ // event:signup:plan:pro → event with property filter
115
+ if (parts.length >= 4) {
116
+ return {
117
+ name: eventName,
118
+ type: "event",
119
+ match_value: eventName,
120
+ property_key: parts[2],
121
+ property_value: parts.slice(3).join(":"),
122
+ }
123
+ }
124
+ return {
125
+ name: eventName,
126
+ type: "event",
127
+ match_value: eventName,
128
+ }
129
+ }
130
+
131
+ throw new Error(
132
+ `Invalid step type "${type}". Use "page", "event", "purchase", or "trial"`
133
+ )
134
+ }
135
+
136
+ /**
137
+ * Get a human-readable label for a step
138
+ */
139
+ function stepLabel(step: { type: string; match_type?: string | null; match_value?: string | null; name?: string }): string {
140
+ if (step.type === "purchase") return "Purchase"
141
+ if (step.type === "trial") return "Trial start"
142
+ if (step.type === "page") {
143
+ const prefix = step.match_type && step.match_type !== "exact" ? `${step.match_type}: ` : ""
144
+ return `${prefix}${step.match_value || step.name || "page"}`
145
+ }
146
+ if (step.type === "event") {
147
+ return step.match_value || step.name || "event"
148
+ }
149
+ return step.name || step.type
150
+ }
151
+
152
+ /**
153
+ * Render funnel analysis results
154
+ */
155
+ function renderAnalysis(rows: FunnelAnalysisRow[], funnel: { name: string; description?: string | null; mode: string; steps: Array<{ name: string; type: string; match_type?: string | null; match_value?: string | null }> }) {
156
+ console.log()
157
+ console.log(chalk.bold(funnel.name))
158
+ if (funnel.description) {
159
+ console.log(chalk.dim(` ${funnel.description}`))
160
+ }
161
+ console.log(chalk.dim(` Mode: ${funnel.mode}`))
162
+ console.log()
163
+
164
+ if (rows.length === 0) {
165
+ console.log(chalk.dim(" No data for the selected period"))
166
+ console.log()
167
+ return
168
+ }
169
+
170
+ const maxVisitors = Math.max(...rows.map((r) => r.visitors))
171
+
172
+ for (const row of rows) {
173
+ // ClickHouse may return "ps.<col>" instead of "<col>" for joined columns
174
+ const rawRow = row as Record<string, unknown>
175
+ const stepOrder = row.step_order ?? (rawRow["ps.step_order"] as number)
176
+ const stepType = row.step_type ?? (rawRow["ps.step_type"] as string)
177
+ const matchType = row.match_type ?? (rawRow["ps.match_type"] as string | null)
178
+ const matchValue = row.match_value ?? (rawRow["ps.match_value"] as string | null)
179
+ const stepNum = `Step ${stepOrder}:`
180
+ const label = stepLabel({
181
+ type: stepType,
182
+ match_type: matchType,
183
+ match_value: matchValue,
184
+ name: funnel.steps[stepOrder - 1]?.name,
185
+ })
186
+ const visitors = formatNumber(row.visitors)
187
+ const bar = sparkBar(row.visitors, maxVisitors, 16)
188
+ const pct = formatPercent(row.conversion_rate_from_start)
189
+
190
+ let dropoff = ""
191
+ if (stepOrder > 1) {
192
+ const drop = row.conversion_rate_from_prev - 100
193
+ dropoff = chalk.red(` (${drop >= 0 ? "+" : ""}${drop.toFixed(1)}%)`)
194
+ }
195
+
196
+ console.log(
197
+ ` ${chalk.dim(stepNum.padEnd(8))} ${label.padEnd(30)} ${visitors.padStart(10)} visitors ${bar} ${pct.padStart(6)}${dropoff}`
198
+ )
199
+ }
200
+
201
+ // Overall conversion
202
+ if (rows.length >= 2) {
203
+ const first = rows[0]
204
+ const last = rows[rows.length - 1]
205
+ const overall = first.visitors > 0 ? (last.visitors / first.visitors) * 100 : 0
206
+ console.log()
207
+ console.log(
208
+ chalk.bold(` Overall conversion: ${formatPercent(overall)}`) +
209
+ chalk.dim(` (${formatNumber(first.visitors)} → ${formatNumber(last.visitors)})`)
210
+ )
211
+ }
212
+ console.log()
213
+ }
214
+
215
+ export const funnelsCommand = new Command("funnels")
216
+ .description("Manage and analyze conversion funnels")
217
+ .addHelpText("after", funnelsExamples)
218
+ .option("-s, --site <site>", "Site to query")
219
+ .option("--json", "Output as JSON")
220
+ .action(async (options) => {
221
+ const site = options.site || (await getDefaultSite())
222
+
223
+ if (!site) {
224
+ console.error(
225
+ chalk.red(
226
+ "Error: No site specified. Use --site or set a default with `supalytics login --site`"
227
+ )
228
+ )
229
+ process.exit(1)
230
+ }
231
+
232
+ try {
233
+ const response = await listFunnels(site)
234
+
235
+ if (options.json) {
236
+ console.log(JSON.stringify(response, null, 2))
237
+ return
238
+ }
239
+
240
+ console.log()
241
+ console.log(chalk.bold("Funnels"))
242
+ console.log()
243
+
244
+ if (response.data.length === 0) {
245
+ console.log(chalk.dim(" No funnels found"))
246
+ console.log()
247
+ console.log(
248
+ chalk.dim(
249
+ ' Create one with: supalytics funnels create "Name" --step "page:/pricing" --step "purchase"'
250
+ )
251
+ )
252
+ console.log()
253
+ return
254
+ }
255
+
256
+ for (const funnel of response.data) {
257
+ const stepCount = funnel.steps?.length || 0
258
+ const created = new Date(funnel.created_at).toISOString().split("T")[0]
259
+ console.log(
260
+ ` ${chalk.cyan(funnel.name.padEnd(30))} ${String(stepCount).padStart(2)} steps ${funnel.mode.padEnd(10)} ${chalk.dim(created)}`
261
+ )
262
+ if (funnel.description) {
263
+ console.log(` ${chalk.dim(funnel.description)}`)
264
+ }
265
+ console.log(chalk.dim(` id: ${funnel.id}`))
266
+ console.log()
267
+ }
268
+ } catch (error) {
269
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
270
+ process.exit(1)
271
+ }
272
+ })
273
+
274
+ // Subcommand: view
275
+ funnelsCommand
276
+ .command("view <id>")
277
+ .description("View funnel details and conversion analysis")
278
+ .option("-s, --site <site>", "Site to query")
279
+ .option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
280
+ .option("--start <date>", "Start date (YYYY-MM-DD)")
281
+ .option("--end <date>", "End date (YYYY-MM-DD)")
282
+ .option("--json", "Output as JSON")
283
+ .action(async (id, _options, cmd) => {
284
+ const options = mergedOpts(cmd)
285
+ const site = await resolveSiteOption(cmd)
286
+
287
+ if (!site) {
288
+ console.error(
289
+ chalk.red(
290
+ "Error: No site specified. Use --site or set a default with `supalytics login --site`"
291
+ )
292
+ )
293
+ process.exit(1)
294
+ }
295
+
296
+ try {
297
+ const dateRange =
298
+ options.start && options.end
299
+ ? ([options.start, options.end] as [string, string])
300
+ : parsePeriod(options.period as string)
301
+
302
+ // Fetch funnel definition and run analysis in parallel
303
+ const [funnelResponse, analysisResponse] = await Promise.all([
304
+ getFunnel(site, id),
305
+ analyzeFunnel(site, id, dateRange),
306
+ ])
307
+
308
+ if (options.json) {
309
+ console.log(
310
+ JSON.stringify(
311
+ {
312
+ funnel: funnelResponse.data,
313
+ analysis: analysisResponse.data,
314
+ meta: analysisResponse.meta,
315
+ },
316
+ null,
317
+ 2
318
+ )
319
+ )
320
+ return
321
+ }
322
+
323
+ const [startDate, endDate] = analysisResponse.meta.date_range
324
+ console.log(chalk.dim(` ${startDate} → ${endDate} (${analysisResponse.meta.query_ms}ms)`))
325
+
326
+ renderAnalysis(analysisResponse.data, funnelResponse.data)
327
+ } catch (error) {
328
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
329
+ process.exit(1)
330
+ }
331
+ })
332
+
333
+ // Subcommand: create
334
+ funnelsCommand
335
+ .command("create <name>")
336
+ .description("Create a new funnel")
337
+ .option("-s, --site <site>", "Site to query")
338
+ .option("--step <definition>", "Step definition (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[])
339
+ .option("--mode <mode>", "Funnel mode: ordered or unordered", "unordered")
340
+ .option("-d, --description <text>", "Optional description")
341
+ .option("--json", "Output as JSON")
342
+ .action(async (name, _options, cmd) => {
343
+ const options = mergedOpts(cmd)
344
+ const site = await resolveSiteOption(cmd)
345
+
346
+ if (!site) {
347
+ console.error(
348
+ chalk.red(
349
+ "Error: No site specified. Use --site or set a default with `supalytics login --site`"
350
+ )
351
+ )
352
+ process.exit(1)
353
+ }
354
+
355
+ const steps = options.step as string[] | undefined
356
+ if (!steps || steps.length === 0) {
357
+ console.error(chalk.red("Error: At least one --step is required"))
358
+ console.error(
359
+ chalk.dim(' Example: --step "page:/pricing" --step "event:signup" --step "purchase"')
360
+ )
361
+ process.exit(1)
362
+ }
363
+
364
+ if (!["ordered", "unordered"].includes(options.mode as string)) {
365
+ console.error(chalk.red("Error: --mode must be 'ordered' or 'unordered'"))
366
+ process.exit(1)
367
+ }
368
+
369
+ try {
370
+ const parsedSteps: FunnelStepInput[] = steps.map((s: string) => parseStep(s))
371
+
372
+ const response = await createFunnel(site, {
373
+ name,
374
+ description: options.description as string | undefined,
375
+ mode: options.mode as string | undefined,
376
+ steps: parsedSteps,
377
+ })
378
+
379
+ if (options.json) {
380
+ console.log(JSON.stringify(response, null, 2))
381
+ return
382
+ }
383
+
384
+ console.log()
385
+ console.log(chalk.green("Funnel created"))
386
+ console.log()
387
+ console.log(` ${chalk.bold(response.data.name)}`)
388
+ if (response.data.description) {
389
+ console.log(` ${chalk.dim(response.data.description)}`)
390
+ }
391
+ console.log(chalk.dim(` Mode: ${response.data.mode}`))
392
+ console.log()
393
+
394
+ for (const step of response.data.steps) {
395
+ console.log(
396
+ ` ${chalk.dim(`Step ${step.step_order}:`)} ${stepLabel(step)}`
397
+ )
398
+ }
399
+ console.log()
400
+ console.log(chalk.dim(` id: ${response.data.id}`))
401
+ console.log()
402
+ } catch (error) {
403
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
404
+ process.exit(1)
405
+ }
406
+ })
407
+
408
+ // Subcommand: update
409
+ funnelsCommand
410
+ .command("update <id>")
411
+ .description("Update an existing funnel")
412
+ .option("-s, --site <site>", "Site to query")
413
+ .option("--name <name>", "New funnel name")
414
+ .option("-d, --description <text>", "New description")
415
+ .option("--mode <mode>", "Funnel mode: ordered or unordered")
416
+ .option("--step <definition>", "Replace steps (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[])
417
+ .option("--json", "Output as JSON")
418
+ .action(async (id, _options, cmd) => {
419
+ const options = mergedOpts(cmd)
420
+ const site = await resolveSiteOption(cmd)
421
+
422
+ if (!site) {
423
+ console.error(
424
+ chalk.red(
425
+ "Error: No site specified. Use --site or set a default with `supalytics login --site`"
426
+ )
427
+ )
428
+ process.exit(1)
429
+ }
430
+
431
+ if (options.mode && !["ordered", "unordered"].includes(options.mode as string)) {
432
+ console.error(chalk.red("Error: --mode must be 'ordered' or 'unordered'"))
433
+ process.exit(1)
434
+ }
435
+
436
+ const updateData: {
437
+ name?: string
438
+ description?: string
439
+ mode?: string
440
+ steps?: FunnelStepInput[]
441
+ } = {}
442
+
443
+ if (options.name) updateData.name = options.name as string
444
+ if (options.description) updateData.description = options.description as string
445
+ if (options.mode) updateData.mode = options.mode as string
446
+ const updateSteps = options.step as string[] | undefined
447
+ if (updateSteps && updateSteps.length > 0) {
448
+ updateData.steps = updateSteps.map((s: string) => parseStep(s))
449
+ }
450
+
451
+ if (Object.keys(updateData).length === 0) {
452
+ console.error(chalk.red("Error: No updates specified"))
453
+ console.error(chalk.dim(" Use --name, --description, --mode, or --step"))
454
+ process.exit(1)
455
+ }
456
+
457
+ try {
458
+ const response = await updateFunnel(site, id, updateData)
459
+
460
+ if (options.json) {
461
+ console.log(JSON.stringify(response, null, 2))
462
+ return
463
+ }
464
+
465
+ console.log()
466
+ console.log(chalk.green("Funnel updated"))
467
+ console.log()
468
+ console.log(` ${chalk.bold(response.data.name)}`)
469
+ if (response.data.description) {
470
+ console.log(` ${chalk.dim(response.data.description)}`)
471
+ }
472
+ console.log(chalk.dim(` Mode: ${response.data.mode}`))
473
+ console.log()
474
+
475
+ for (const step of response.data.steps) {
476
+ console.log(
477
+ ` ${chalk.dim(`Step ${step.step_order}:`)} ${stepLabel(step)}`
478
+ )
479
+ }
480
+ console.log()
481
+ } catch (error) {
482
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
483
+ process.exit(1)
484
+ }
485
+ })
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { query, formatNumber } from "../api";
4
4
  import { getDefaultSite } from "../config";
5
- import { sparkBar, truncate, formatRevenue } from "../ui";
5
+ import { sparkBar, truncate, formatRevenue, parsePeriod } from "../ui";
6
6
 
7
7
  export const pagesCommand = new Command("pages")
8
8
  .description("Top pages by visitors")
@@ -25,7 +25,7 @@ export const pagesCommand = new Command("pages")
25
25
 
26
26
  const dateRange = options.start && options.end
27
27
  ? [options.start, options.end] as [string, string]
28
- : options.period;
28
+ : parsePeriod(options.period);
29
29
 
30
30
  const filters = options.filter
31
31
  ? options.filter.map((f: string) => {
@@ -2,7 +2,7 @@ import chalk from "chalk"
2
2
  import { Command } from "commander"
3
3
  import { formatDuration, formatNumber, formatPercent, query } from "../api"
4
4
  import { getDefaultSite } from "../config"
5
- import { type TableColumn, table } from "../ui"
5
+ import { type TableColumn, table, parsePeriod } from "../ui"
6
6
 
7
7
  const queryExamples = `
8
8
  Examples:
@@ -75,7 +75,7 @@ export const queryCommand = new Command("query")
75
75
  const dateRange =
76
76
  options.start && options.end
77
77
  ? ([options.start, options.end] as [string, string])
78
- : options.period
78
+ : parsePeriod(options.period)
79
79
 
80
80
  // Parse metrics
81
81
  const metrics = options.metrics.split(",").map((m: string) => m.trim())
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import { query, formatNumber } from "../api";
4
4
  import { getDefaultSite } from "../config";
5
- import { sparkBar, truncate, formatRevenue } from "../ui";
5
+ import { sparkBar, truncate, formatRevenue, parsePeriod } from "../ui";
6
6
 
7
7
  export const referrersCommand = new Command("referrers")
8
8
  .description("Top referrers")
@@ -25,7 +25,7 @@ export const referrersCommand = new Command("referrers")
25
25
 
26
26
  const dateRange = options.start && options.end
27
27
  ? [options.start, options.end] as [string, string]
28
- : options.period;
28
+ : parsePeriod(options.period);
29
29
 
30
30
  const filters = options.filter
31
31
  ? options.filter.map((f: string) => {