@supalytics/cli 0.4.2 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supalytics/cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "CLI for Supalytics web analytics",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.ts CHANGED
@@ -159,7 +159,7 @@ export interface PropertyBreakdownResponse {
159
159
  */
160
160
  export async function listEvents(
161
161
  site: string,
162
- period: string = "30d",
162
+ dateRange: string | [string, string] = "30d",
163
163
  limit: number = 100,
164
164
  isDev: boolean = false
165
165
  ): Promise<EventsResponse> {
@@ -177,7 +177,13 @@ export async function listEvents(
177
177
  );
178
178
  }
179
179
 
180
- const params = new URLSearchParams({ period, limit: String(limit), domain: site });
180
+ const params = new URLSearchParams({ limit: String(limit), domain: site });
181
+ if (Array.isArray(dateRange)) {
182
+ params.set("start", dateRange[0]);
183
+ params.set("end", dateRange[1]);
184
+ } else {
185
+ params.set("period", dateRange);
186
+ }
181
187
  if (isDev) {
182
188
  params.set("is_dev", "true");
183
189
  }
@@ -199,7 +205,7 @@ export async function listEvents(
199
205
  export async function getEventProperties(
200
206
  site: string,
201
207
  eventName: string,
202
- period: string = "30d",
208
+ dateRange: string | [string, string] = "30d",
203
209
  isDev: boolean = false
204
210
  ): Promise<PropertyKeysResponse> {
205
211
  const apiKey = await getApiKeyForSite(site);
@@ -208,7 +214,13 @@ export async function getEventProperties(
208
214
  throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
209
215
  }
210
216
 
211
- const params = new URLSearchParams({ period, domain: site });
217
+ const params = new URLSearchParams({ domain: site });
218
+ if (Array.isArray(dateRange)) {
219
+ params.set("start", dateRange[0]);
220
+ params.set("end", dateRange[1]);
221
+ } else {
222
+ params.set("period", dateRange);
223
+ }
212
224
  if (isDev) {
213
225
  params.set("is_dev", "true");
214
226
  }
@@ -232,7 +244,7 @@ export async function getPropertyBreakdown(
232
244
  site: string,
233
245
  eventName: string,
234
246
  propertyKey: string,
235
- period: string = "30d",
247
+ dateRange: string | [string, string] = "30d",
236
248
  limit: number = 100,
237
249
  includeRevenue: boolean = false,
238
250
  isDev: boolean = false
@@ -244,11 +256,16 @@ export async function getPropertyBreakdown(
244
256
  }
245
257
 
246
258
  const params = new URLSearchParams({
247
- period,
248
259
  limit: String(limit),
249
260
  include_revenue: String(includeRevenue),
250
261
  domain: site,
251
262
  });
263
+ if (Array.isArray(dateRange)) {
264
+ params.set("start", dateRange[0]);
265
+ params.set("end", dateRange[1]);
266
+ } else {
267
+ params.set("period", dateRange);
268
+ }
252
269
  if (isDev) {
253
270
  params.set("is_dev", "true");
254
271
  }
@@ -351,7 +368,7 @@ export interface AnnotationsResponse {
351
368
  */
352
369
  export async function listAnnotations(
353
370
  site: string,
354
- period: string = "30d"
371
+ dateRange: string | [string, string] = "30d"
355
372
  ): Promise<AnnotationsResponse> {
356
373
  const apiKey = await getApiKeyForSite(site);
357
374
 
@@ -367,36 +384,13 @@ export async function listAnnotations(
367
384
  );
368
385
  }
369
386
 
370
- // Valid periods
371
- const validPeriods = ["7d", "14d", "30d", "90d", "12mo", "all"] as const;
372
- if (!validPeriods.includes(period as typeof validPeriods[number])) {
373
- throw new Error(`Invalid period "${period}". Valid periods: ${validPeriods.join(", ")}`);
374
- }
375
-
376
- // Calculate date range from period
377
- const endDate = new Date();
378
- let startDate = new Date();
379
-
380
- if (period === "7d") {
381
- startDate.setDate(endDate.getDate() - 7);
382
- } else if (period === "14d") {
383
- startDate.setDate(endDate.getDate() - 14);
384
- } else if (period === "30d") {
385
- startDate.setDate(endDate.getDate() - 30);
386
- } else if (period === "90d") {
387
- startDate.setDate(endDate.getDate() - 90);
388
- } else if (period === "12mo") {
389
- startDate.setMonth(endDate.getMonth() - 12);
390
- } else if (period === "all") {
391
- startDate = new Date("2020-01-01");
387
+ const params = new URLSearchParams({ domain: site });
388
+ if (Array.isArray(dateRange)) {
389
+ params.set("start_date", dateRange[0]);
390
+ params.set("end_date", dateRange[1]);
392
391
  }
393
-
394
- const formatDate = (d: Date) => d.toISOString().split("T")[0];
395
- const params = new URLSearchParams({
396
- domain: site,
397
- start_date: formatDate(startDate),
398
- end_date: formatDate(endDate),
399
- });
392
+ // Annotations API only supports start_date/end_date, not period.
393
+ // String values like "all" are left without date params so the API returns everything.
400
394
 
401
395
  const response = await fetch(`${API_BASE}/v1/annotations?${params}`, {
402
396
  headers: { Authorization: `Bearer ${apiKey}` },
@@ -734,6 +728,235 @@ export async function getJourneyStats(
734
728
  return response.json();
735
729
  }
736
730
 
731
+ // Funnels API types
732
+ export interface FunnelStep {
733
+ id: string;
734
+ step_order: number;
735
+ name: string;
736
+ type: string; // 'page' | 'event' | 'purchase' | 'trial'
737
+ match_type?: string | null;
738
+ match_value?: string | null;
739
+ property_key?: string | null;
740
+ property_value?: string | null;
741
+ }
742
+
743
+ export interface FunnelItem {
744
+ id: string;
745
+ name: string;
746
+ description?: string | null;
747
+ mode: string; // 'ordered' | 'unordered'
748
+ steps: FunnelStep[];
749
+ created_at: string;
750
+ updated_at: string;
751
+ }
752
+
753
+ export interface FunnelsListResponse {
754
+ data: FunnelItem[];
755
+ meta: { domain: string };
756
+ }
757
+
758
+ export interface FunnelResponse {
759
+ data: FunnelItem;
760
+ meta: { domain: string };
761
+ }
762
+
763
+ export interface FunnelAnalysisRow {
764
+ step_order: number;
765
+ step_type: string;
766
+ match_type: string | null;
767
+ match_value: string | null;
768
+ property_key: string | null;
769
+ property_value: string | null;
770
+ visitors: number;
771
+ conversion_rate_from_start: number;
772
+ conversion_rate_from_prev: number;
773
+ }
774
+
775
+ export interface FunnelAnalysisResponse {
776
+ data: FunnelAnalysisRow[];
777
+ meta: {
778
+ domain: string;
779
+ date_range: [string, string];
780
+ query_ms: number;
781
+ };
782
+ }
783
+
784
+ export interface FunnelStepInput {
785
+ name: string;
786
+ type: string;
787
+ match_type?: string;
788
+ match_value?: string;
789
+ property_key?: string;
790
+ property_value?: string;
791
+ }
792
+
793
+ /**
794
+ * List all funnels for a site
795
+ */
796
+ export async function listFunnels(site: string): Promise<FunnelsListResponse> {
797
+ const apiKey = await getApiKeyForSite(site);
798
+
799
+ if (!apiKey) {
800
+ const sites = await getSites();
801
+ if (sites.length === 0) {
802
+ throw new Error(
803
+ "No sites configured. Run `supalytics login` to authenticate."
804
+ );
805
+ }
806
+ throw new Error(
807
+ `Not authenticated or site '${site}' not found. Available sites: ${sites.join(", ")}`
808
+ );
809
+ }
810
+
811
+ const params = new URLSearchParams({ domain: site });
812
+ const response = await fetch(`${API_BASE}/v1/funnels?${params}`, {
813
+ headers: { Authorization: `Bearer ${apiKey}` },
814
+ });
815
+
816
+ if (!response.ok) {
817
+ const error = (await response.json()) as ApiError;
818
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
819
+ }
820
+
821
+ return response.json();
822
+ }
823
+
824
+ /**
825
+ * Get a single funnel
826
+ */
827
+ export async function getFunnel(
828
+ site: string,
829
+ funnelId: string
830
+ ): Promise<FunnelResponse> {
831
+ const apiKey = await getApiKeyForSite(site);
832
+
833
+ if (!apiKey) {
834
+ throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
835
+ }
836
+
837
+ const params = new URLSearchParams({ domain: site });
838
+ const response = await fetch(`${API_BASE}/v1/funnels/${funnelId}?${params}`, {
839
+ headers: { Authorization: `Bearer ${apiKey}` },
840
+ });
841
+
842
+ if (!response.ok) {
843
+ const error = (await response.json()) as ApiError;
844
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
845
+ }
846
+
847
+ return response.json();
848
+ }
849
+
850
+ /**
851
+ * Create a funnel
852
+ */
853
+ export async function createFunnel(
854
+ site: string,
855
+ data: {
856
+ name: string;
857
+ description?: string;
858
+ mode?: string;
859
+ steps: FunnelStepInput[];
860
+ }
861
+ ): Promise<FunnelResponse> {
862
+ const apiKey = await getApiKeyForSite(site);
863
+
864
+ if (!apiKey) {
865
+ throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
866
+ }
867
+
868
+ const response = await fetch(`${API_BASE}/v1/funnels`, {
869
+ method: "POST",
870
+ headers: {
871
+ Authorization: `Bearer ${apiKey}`,
872
+ "Content-Type": "application/json",
873
+ },
874
+ body: JSON.stringify({ ...data, domain: site }),
875
+ });
876
+
877
+ if (!response.ok) {
878
+ const error = (await response.json()) as ApiError;
879
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
880
+ }
881
+
882
+ return response.json();
883
+ }
884
+
885
+ /**
886
+ * Update a funnel
887
+ */
888
+ export async function updateFunnel(
889
+ site: string,
890
+ funnelId: string,
891
+ data: {
892
+ name?: string;
893
+ description?: string;
894
+ mode?: string;
895
+ steps?: FunnelStepInput[];
896
+ }
897
+ ): Promise<FunnelResponse> {
898
+ const apiKey = await getApiKeyForSite(site);
899
+
900
+ if (!apiKey) {
901
+ throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
902
+ }
903
+
904
+ const response = await fetch(`${API_BASE}/v1/funnels/${funnelId}`, {
905
+ method: "PUT",
906
+ headers: {
907
+ Authorization: `Bearer ${apiKey}`,
908
+ "Content-Type": "application/json",
909
+ },
910
+ body: JSON.stringify({ ...data, domain: site }),
911
+ });
912
+
913
+ if (!response.ok) {
914
+ const error = (await response.json()) as ApiError;
915
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
916
+ }
917
+
918
+ return response.json();
919
+ }
920
+
921
+ /**
922
+ * Run funnel analysis
923
+ */
924
+ export async function analyzeFunnel(
925
+ site: string,
926
+ funnelId: string,
927
+ dateRange: string | [string, string] = "30d",
928
+ isDev: boolean = false
929
+ ): Promise<FunnelAnalysisResponse> {
930
+ const apiKey = await getApiKeyForSite(site);
931
+
932
+ if (!apiKey) {
933
+ throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
934
+ }
935
+
936
+ const body: Record<string, unknown> = { domain: site, is_dev: isDev };
937
+ if (Array.isArray(dateRange)) {
938
+ body.date_range = dateRange;
939
+ } else {
940
+ body.date_range = dateRange;
941
+ }
942
+
943
+ const response = await fetch(`${API_BASE}/v1/funnels/${funnelId}/analyze`, {
944
+ method: "POST",
945
+ headers: {
946
+ Authorization: `Bearer ${apiKey}`,
947
+ "Content-Type": "application/json",
948
+ },
949
+ body: JSON.stringify(body),
950
+ });
951
+
952
+ if (!response.ok) {
953
+ const error = (await response.json()) as ApiError;
954
+ throw new Error(error.message || error.error || `HTTP ${response.status}`);
955
+ }
956
+
957
+ return response.json();
958
+ }
959
+
737
960
  /**
738
961
  * Format revenue in cents to dollars
739
962
  */
@@ -2,6 +2,23 @@ import chalk from "chalk"
2
2
  import { Command } from "commander"
3
3
  import { createAnnotation, deleteAnnotation, listAnnotations } from "../api"
4
4
  import { getDefaultSite } from "../config"
5
+ import { parsePeriod } from "../ui"
6
+
7
+ /**
8
+ * Merge subcommand options with parent command options.
9
+ * Commander doesn't propagate options like -s/--site and --json
10
+ * from parent to subcommands, so we check both.
11
+ */
12
+ function mergedOpts(cmd: Command): Record<string, unknown> {
13
+ const parentOpts = cmd.parent?.opts() || {}
14
+ const ownOpts = cmd.opts()
15
+ return { ...parentOpts, ...ownOpts }
16
+ }
17
+
18
+ async function resolveSiteOption(cmd: Command): Promise<string | undefined> {
19
+ const opts = mergedOpts(cmd)
20
+ return (opts.site as string) || (await getDefaultSite())
21
+ }
5
22
 
6
23
  const annotationsExamples = `
7
24
  Examples:
@@ -23,10 +40,10 @@ Examples:
23
40
  export const annotationsCommand = new Command("annotations")
24
41
  .description("Manage chart annotations")
25
42
  .addHelpText("after", annotationsExamples)
26
- .argument("[period]", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
27
43
  .option("-s, --site <site>", "Site to query")
44
+ .option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
28
45
  .option("--json", "Output as JSON")
29
- .action(async (period, options) => {
46
+ .action(async (options) => {
30
47
  const site = options.site || (await getDefaultSite())
31
48
 
32
49
  if (!site) {
@@ -39,7 +56,7 @@ export const annotationsCommand = new Command("annotations")
39
56
  }
40
57
 
41
58
  try {
42
- const response = await listAnnotations(site, period)
59
+ const response = await listAnnotations(site, parsePeriod(options.period))
43
60
 
44
61
  if (options.json) {
45
62
  console.log(JSON.stringify(response, null, 2))
@@ -80,8 +97,9 @@ annotationsCommand
80
97
  .option("-s, --site <site>", "Site to query")
81
98
  .option("-d, --description <text>", "Optional description")
82
99
  .option("--json", "Output as JSON")
83
- .action(async (date, title, options) => {
84
- const site = options.site || (await getDefaultSite())
100
+ .action(async (date, title, _options, cmd) => {
101
+ const options = mergedOpts(cmd)
102
+ const site = await resolveSiteOption(cmd)
85
103
 
86
104
  if (!site) {
87
105
  console.error(
@@ -99,7 +117,7 @@ annotationsCommand
99
117
  }
100
118
 
101
119
  try {
102
- const annotation = await createAnnotation(site, date, title, options.description)
120
+ const annotation = await createAnnotation(site, date, title, options.description as string | undefined)
103
121
 
104
122
  if (options.json) {
105
123
  console.log(JSON.stringify(annotation, null, 2))
@@ -126,8 +144,8 @@ annotationsCommand
126
144
  .command("remove <id>")
127
145
  .description("Remove an annotation")
128
146
  .option("-s, --site <site>", "Site to query")
129
- .action(async (id, options) => {
130
- const site = options.site || (await getDefaultSite())
147
+ .action(async (id, _options, cmd) => {
148
+ const site = await resolveSiteOption(cmd)
131
149
 
132
150
  if (!site) {
133
151
  console.error(
@@ -10,7 +10,7 @@ _supalytics_completions() {
10
10
  cur="\${COMP_WORDS[COMP_CWORD]}"
11
11
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
12
12
 
13
- commands="init login logout sites default remove stats pages referrers countries trend query events realtime completions help"
13
+ commands="init login logout sites default remove stats pages referrers countries trend query events annotations realtime completions help"
14
14
 
15
15
  # Main command completion
16
16
  if [[ \${COMP_CWORD} -eq 1 ]]; then
@@ -21,7 +21,7 @@ _supalytics_completions() {
21
21
  # Subcommand options
22
22
  case "\${COMP_WORDS[1]}" in
23
23
  stats)
24
- opts="today yesterday week month year 7d 14d 30d 90d 12mo all --site --start --end --filter --all --no-revenue --json"
24
+ opts="--site --period --start --end --filter --all --no-revenue --json"
25
25
  ;;
26
26
  pages|referrers|countries)
27
27
  opts="--site --period --start --end --limit --filter --no-revenue --json"
@@ -35,6 +35,9 @@ _supalytics_completions() {
35
35
  events)
36
36
  opts="--site --period --property --limit --no-revenue --json"
37
37
  ;;
38
+ annotations)
39
+ opts="--site --period --json add remove"
40
+ ;;
38
41
  realtime)
39
42
  opts="--site --json --watch"
40
43
  ;;
@@ -86,6 +89,7 @@ _supalytics() {
86
89
  'trend:Daily visitor trend'
87
90
  'query:Flexible query with custom metrics and dimensions'
88
91
  'events:List and explore custom events'
92
+ 'annotations:Manage chart annotations'
89
93
  'realtime:Live visitors on your site right now'
90
94
  'completions:Generate shell completions'
91
95
  'help:Display help for command'
@@ -118,19 +122,14 @@ _supalytics() {
118
122
  case \$words[1] in
119
123
  stats)
120
124
  _arguments \\
121
- '1: :->period' \\
122
125
  '--site[Site to query]:domain:' \\
126
+ '--period[Time period]:period:(today yesterday week month year 7d 14d 30d 90d 12mo all)' \\
123
127
  '--start[Start date]:date:' \\
124
128
  '--end[End date]:date:' \\
125
129
  '*--filter[Filter]:filter:' \\
126
130
  '--all[Show detailed breakdown]' \\
127
131
  '--no-revenue[Exclude revenue metrics]' \\
128
132
  '--json[Output as JSON]'
129
- case \$state in
130
- period)
131
- _describe 'period' period_opts
132
- ;;
133
- esac
134
133
  ;;
135
134
  pages|referrers|countries)
136
135
  _arguments \\
@@ -180,6 +179,12 @@ _supalytics() {
180
179
  '--no-revenue[Exclude revenue]' \\
181
180
  '--json[Output as JSON]'
182
181
  ;;
182
+ annotations)
183
+ _arguments \\
184
+ '--site[Site to query]:domain:' \\
185
+ '--period[Time period]:period:(7d 14d 30d 90d 12mo all)' \\
186
+ '--json[Output as JSON]'
187
+ ;;
183
188
  realtime)
184
189
  _arguments \\
185
190
  '--site[Site to query]:domain:' \\
@@ -236,13 +241,14 @@ complete -c supalytics -n "__fish_use_subcommand" -a "countries" -d "Traffic by
236
241
  complete -c supalytics -n "__fish_use_subcommand" -a "trend" -d "Daily visitor trend"
237
242
  complete -c supalytics -n "__fish_use_subcommand" -a "query" -d "Flexible query with custom metrics"
238
243
  complete -c supalytics -n "__fish_use_subcommand" -a "events" -d "List and explore custom events"
244
+ complete -c supalytics -n "__fish_use_subcommand" -a "annotations" -d "Manage chart annotations"
239
245
  complete -c supalytics -n "__fish_use_subcommand" -a "realtime" -d "Live visitors right now"
240
246
  complete -c supalytics -n "__fish_use_subcommand" -a "completions" -d "Generate shell completions"
241
247
  complete -c supalytics -n "__fish_use_subcommand" -a "help" -d "Display help for command"
242
248
 
243
249
  # Stats options
244
- complete -c supalytics -n "__fish_seen_subcommand_from stats" -a "today yesterday week month year 7d 14d 30d 90d 12mo all"
245
250
  complete -c supalytics -n "__fish_seen_subcommand_from stats" -l site -s s -d "Site to query"
251
+ complete -c supalytics -n "__fish_seen_subcommand_from stats" -l period -s p -d "Time period" -a "today yesterday week month year 7d 14d 30d 90d 12mo all"
246
252
  complete -c supalytics -n "__fish_seen_subcommand_from stats" -l start -d "Start date"
247
253
  complete -c supalytics -n "__fish_seen_subcommand_from stats" -l end -d "End date"
248
254
  complete -c supalytics -n "__fish_seen_subcommand_from stats" -l filter -s f -d "Filter"
@@ -293,6 +299,11 @@ complete -c supalytics -n "__fish_seen_subcommand_from events" -l limit -s l -d
293
299
  complete -c supalytics -n "__fish_seen_subcommand_from events" -l no-revenue -d "Exclude revenue"
294
300
  complete -c supalytics -n "__fish_seen_subcommand_from events" -l json -d "Output as JSON"
295
301
 
302
+ # Annotations options
303
+ complete -c supalytics -n "__fish_seen_subcommand_from annotations" -l site -s s -d "Site to query"
304
+ complete -c supalytics -n "__fish_seen_subcommand_from annotations" -l period -s p -d "Time period" -a "7d 14d 30d 90d 12mo all"
305
+ complete -c supalytics -n "__fish_seen_subcommand_from annotations" -l json -d "Output as JSON"
306
+
296
307
  # Realtime options
297
308
  complete -c supalytics -n "__fish_seen_subcommand_from realtime" -l site -s s -d "Site to query"
298
309
  complete -c supalytics -n "__fish_seen_subcommand_from realtime" -l json -d "Output as JSON"
@@ -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, formatRevenue } from "../ui";
5
+ import { sparkBar, formatRevenue, parsePeriod } from "../ui";
6
6
 
7
7
  export const countriesCommand = new Command("countries")
8
8
  .description("Traffic by country")
@@ -25,7 +25,7 @@ export const countriesCommand = new Command("countries")
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) => {
@@ -8,6 +8,7 @@ import {
8
8
  type PropertyKeysResponse,
9
9
  } from "../api"
10
10
  import { getDefaultSite } from "../config"
11
+ import { parsePeriod } from "../ui"
11
12
 
12
13
  const eventsExamples = `
13
14
  Examples:
@@ -20,6 +21,10 @@ Examples:
20
21
  # Get breakdown of a property
21
22
  supalytics events signup --property plan
22
23
 
24
+ # With custom date range
25
+ supalytics events --start 2026-01-01 --end 2026-01-31
26
+ supalytics events signup --start 2026-01-01 --end 2026-01-31
27
+
23
28
  # Without revenue
24
29
  supalytics events signup --property plan --no-revenue`
25
30
 
@@ -52,6 +57,8 @@ export const eventsCommand = new Command("events")
52
57
  .argument("[event]", "Event name to explore")
53
58
  .option("-s, --site <site>", "Site to query")
54
59
  .option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
60
+ .option("--start <date>", "Start date (YYYY-MM-DD)")
61
+ .option("--end <date>", "End date (YYYY-MM-DD)")
55
62
  .option("--property <key>", "Get breakdown for a specific property")
56
63
  .option("-l, --limit <number>", "Number of results", "20")
57
64
  .option("--no-revenue", "Exclude revenue metrics")
@@ -59,6 +66,9 @@ export const eventsCommand = new Command("events")
59
66
  .option("-t, --test", "Test mode: query localhost data instead of production")
60
67
  .action(async (event, options) => {
61
68
  const site = options.site || (await getDefaultSite())
69
+ const dateRange = options.start && options.end
70
+ ? [options.start, options.end] as [string, string]
71
+ : parsePeriod(options.period)
62
72
 
63
73
  if (!site) {
64
74
  console.error(
@@ -74,7 +84,7 @@ export const eventsCommand = new Command("events")
74
84
  if (!event) {
75
85
  const response = await listEvents(
76
86
  site,
77
- options.period,
87
+ dateRange,
78
88
  parseInt(options.limit),
79
89
  options.test || false
80
90
  )
@@ -110,7 +120,7 @@ export const eventsCommand = new Command("events")
110
120
  site,
111
121
  event,
112
122
  options.property,
113
- options.period,
123
+ dateRange,
114
124
  parseInt(options.limit),
115
125
  options.revenue !== false,
116
126
  options.test || false
@@ -146,8 +156,8 @@ export const eventsCommand = new Command("events")
146
156
 
147
157
  // If just event name, show event stats + properties
148
158
  const [eventsResponse, propsResponse] = await Promise.all([
149
- listEvents(site, options.period, 100, options.test || false), // Get all events to find this one
150
- getEventProperties(site, event, options.period, options.test || false),
159
+ listEvents(site, dateRange, 100, options.test || false), // Get all events to find this one
160
+ getEventProperties(site, event, dateRange, options.test || false),
151
161
  ])
152
162
 
153
163
  // Find the specific event stats
@@ -0,0 +1,471 @@
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 <id>
41
+
42
+ # View with custom date range
43
+ supalytics funnels <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
+ .argument("[id]", "Funnel ID to view details and conversion analysis")
219
+ .option("-s, --site <site>", "Site to query")
220
+ .option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
221
+ .option("--start <date>", "Start date (YYYY-MM-DD)")
222
+ .option("--end <date>", "End date (YYYY-MM-DD)")
223
+ .option("--json", "Output as JSON")
224
+ .action(async (id, options) => {
225
+ const site = options.site || (await getDefaultSite())
226
+
227
+ if (!site) {
228
+ console.error(
229
+ chalk.red(
230
+ "Error: No site specified. Use --site or set a default with `supalytics login --site`"
231
+ )
232
+ )
233
+ process.exit(1)
234
+ }
235
+
236
+ // If an ID is provided, show funnel details + analysis
237
+ if (id) {
238
+ try {
239
+ const dateRange =
240
+ options.start && options.end
241
+ ? ([options.start, options.end] as [string, string])
242
+ : parsePeriod(options.period as string)
243
+
244
+ // Fetch funnel definition and run analysis in parallel
245
+ const [funnelResponse, analysisResponse] = await Promise.all([
246
+ getFunnel(site, id),
247
+ analyzeFunnel(site, id, dateRange),
248
+ ])
249
+
250
+ if (options.json) {
251
+ console.log(
252
+ JSON.stringify(
253
+ {
254
+ funnel: funnelResponse.data,
255
+ analysis: analysisResponse.data,
256
+ meta: analysisResponse.meta,
257
+ },
258
+ null,
259
+ 2
260
+ )
261
+ )
262
+ return
263
+ }
264
+
265
+ const [startDate, endDate] = analysisResponse.meta.date_range
266
+ console.log(chalk.dim(` ${startDate} → ${endDate} (${analysisResponse.meta.query_ms}ms)`))
267
+
268
+ renderAnalysis(analysisResponse.data, funnelResponse.data)
269
+ } catch (error) {
270
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
271
+ process.exit(1)
272
+ }
273
+ return
274
+ }
275
+
276
+ // No ID — list all funnels
277
+ try {
278
+ const response = await listFunnels(site)
279
+
280
+ if (options.json) {
281
+ console.log(JSON.stringify(response, null, 2))
282
+ return
283
+ }
284
+
285
+ console.log()
286
+ console.log(chalk.bold("Funnels"))
287
+ console.log()
288
+
289
+ if (response.data.length === 0) {
290
+ console.log(chalk.dim(" No funnels found"))
291
+ console.log()
292
+ console.log(
293
+ chalk.dim(
294
+ ' Create one with: supalytics funnels create "Name" --step "page:/pricing" --step "purchase"'
295
+ )
296
+ )
297
+ console.log()
298
+ return
299
+ }
300
+
301
+ for (const funnel of response.data) {
302
+ const stepCount = funnel.steps?.length || 0
303
+ const created = new Date(funnel.created_at).toISOString().split("T")[0]
304
+ console.log(
305
+ ` ${chalk.cyan(funnel.name.padEnd(30))} ${String(stepCount).padStart(2)} steps ${funnel.mode.padEnd(10)} ${chalk.dim(created)}`
306
+ )
307
+ if (funnel.description) {
308
+ console.log(` ${chalk.dim(funnel.description)}`)
309
+ }
310
+ console.log(chalk.dim(` id: ${funnel.id}`))
311
+ console.log()
312
+ }
313
+ } catch (error) {
314
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
315
+ process.exit(1)
316
+ }
317
+ })
318
+
319
+ // Subcommand: create
320
+ funnelsCommand
321
+ .command("create <name>")
322
+ .description("Create a new funnel")
323
+ .option("-s, --site <site>", "Site to query")
324
+ .option("--step <definition>", "Step definition (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[])
325
+ .option("--mode <mode>", "Funnel mode: ordered or unordered", "unordered")
326
+ .option("-d, --description <text>", "Optional description")
327
+ .option("--json", "Output as JSON")
328
+ .action(async (name, _options, cmd) => {
329
+ const options = mergedOpts(cmd)
330
+ const site = await resolveSiteOption(cmd)
331
+
332
+ if (!site) {
333
+ console.error(
334
+ chalk.red(
335
+ "Error: No site specified. Use --site or set a default with `supalytics login --site`"
336
+ )
337
+ )
338
+ process.exit(1)
339
+ }
340
+
341
+ const steps = options.step as string[] | undefined
342
+ if (!steps || steps.length === 0) {
343
+ console.error(chalk.red("Error: At least one --step is required"))
344
+ console.error(
345
+ chalk.dim(' Example: --step "page:/pricing" --step "event:signup" --step "purchase"')
346
+ )
347
+ process.exit(1)
348
+ }
349
+
350
+ if (!["ordered", "unordered"].includes(options.mode as string)) {
351
+ console.error(chalk.red("Error: --mode must be 'ordered' or 'unordered'"))
352
+ process.exit(1)
353
+ }
354
+
355
+ try {
356
+ const parsedSteps: FunnelStepInput[] = steps.map((s: string) => parseStep(s))
357
+
358
+ const response = await createFunnel(site, {
359
+ name,
360
+ description: options.description as string | undefined,
361
+ mode: options.mode as string | undefined,
362
+ steps: parsedSteps,
363
+ })
364
+
365
+ if (options.json) {
366
+ console.log(JSON.stringify(response, null, 2))
367
+ return
368
+ }
369
+
370
+ console.log()
371
+ console.log(chalk.green("Funnel created"))
372
+ console.log()
373
+ console.log(` ${chalk.bold(response.data.name)}`)
374
+ if (response.data.description) {
375
+ console.log(` ${chalk.dim(response.data.description)}`)
376
+ }
377
+ console.log(chalk.dim(` Mode: ${response.data.mode}`))
378
+ console.log()
379
+
380
+ for (const step of response.data.steps) {
381
+ console.log(
382
+ ` ${chalk.dim(`Step ${step.step_order}:`)} ${stepLabel(step)}`
383
+ )
384
+ }
385
+ console.log()
386
+ console.log(chalk.dim(` id: ${response.data.id}`))
387
+ console.log()
388
+ } catch (error) {
389
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
390
+ process.exit(1)
391
+ }
392
+ })
393
+
394
+ // Subcommand: update
395
+ funnelsCommand
396
+ .command("update <id>")
397
+ .description("Update an existing funnel")
398
+ .option("-s, --site <site>", "Site to query")
399
+ .option("--name <name>", "New funnel name")
400
+ .option("-d, --description <text>", "New description")
401
+ .option("--mode <mode>", "Funnel mode: ordered or unordered")
402
+ .option("--step <definition>", "Replace steps (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[])
403
+ .option("--json", "Output as JSON")
404
+ .action(async (id, _options, cmd) => {
405
+ const options = mergedOpts(cmd)
406
+ const site = await resolveSiteOption(cmd)
407
+
408
+ if (!site) {
409
+ console.error(
410
+ chalk.red(
411
+ "Error: No site specified. Use --site or set a default with `supalytics login --site`"
412
+ )
413
+ )
414
+ process.exit(1)
415
+ }
416
+
417
+ if (options.mode && !["ordered", "unordered"].includes(options.mode as string)) {
418
+ console.error(chalk.red("Error: --mode must be 'ordered' or 'unordered'"))
419
+ process.exit(1)
420
+ }
421
+
422
+ const updateData: {
423
+ name?: string
424
+ description?: string
425
+ mode?: string
426
+ steps?: FunnelStepInput[]
427
+ } = {}
428
+
429
+ if (options.name) updateData.name = options.name as string
430
+ if (options.description) updateData.description = options.description as string
431
+ if (options.mode) updateData.mode = options.mode as string
432
+ const updateSteps = options.step as string[] | undefined
433
+ if (updateSteps && updateSteps.length > 0) {
434
+ updateData.steps = updateSteps.map((s: string) => parseStep(s))
435
+ }
436
+
437
+ if (Object.keys(updateData).length === 0) {
438
+ console.error(chalk.red("Error: No updates specified"))
439
+ console.error(chalk.dim(" Use --name, --description, --mode, or --step"))
440
+ process.exit(1)
441
+ }
442
+
443
+ try {
444
+ const response = await updateFunnel(site, id, updateData)
445
+
446
+ if (options.json) {
447
+ console.log(JSON.stringify(response, null, 2))
448
+ return
449
+ }
450
+
451
+ console.log()
452
+ console.log(chalk.green("Funnel updated"))
453
+ console.log()
454
+ console.log(` ${chalk.bold(response.data.name)}`)
455
+ if (response.data.description) {
456
+ console.log(` ${chalk.dim(response.data.description)}`)
457
+ }
458
+ console.log(chalk.dim(` Mode: ${response.data.mode}`))
459
+ console.log()
460
+
461
+ for (const step of response.data.steps) {
462
+ console.log(
463
+ ` ${chalk.dim(`Step ${step.step_order}:`)} ${stepLabel(step)}`
464
+ )
465
+ }
466
+ console.log()
467
+ } catch (error) {
468
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
469
+ process.exit(1)
470
+ }
471
+ })
@@ -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) => {
@@ -6,8 +6,8 @@ import { logo, parsePeriod, truncate } from "../ui";
6
6
 
7
7
  export const statsCommand = new Command("stats")
8
8
  .description("Overview stats: pageviews, visitors, bounce rate, revenue")
9
- .argument("[period]", "Time period: today, yesterday, week, month, year, 7d, 30d, 90d, 12mo, all", "30d")
10
9
  .option("-s, --site <site>", "Site to query")
10
+ .option("-p, --period <period>", "Time period: today, yesterday, week, month, year, 7d, 30d, 90d, 12mo, all", "30d")
11
11
  .option("--start <date>", "Start date (YYYY-MM-DD)")
12
12
  .option("--end <date>", "End date (YYYY-MM-DD)")
13
13
  .option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
@@ -15,7 +15,7 @@ export const statsCommand = new Command("stats")
15
15
  .option("--no-revenue", "Exclude revenue metrics")
16
16
  .option("--json", "Output as JSON")
17
17
  .option("-t, --test", "Test mode: query localhost data instead of production")
18
- .action(async (period, options) => {
18
+ .action(async (options) => {
19
19
  const site = options.site || (await getDefaultSite());
20
20
 
21
21
  if (!site) {
@@ -25,7 +25,7 @@ export const statsCommand = new Command("stats")
25
25
 
26
26
  const dateRange = options.start && options.end
27
27
  ? [options.start, options.end] as [string, string]
28
- : parsePeriod(period);
28
+ : parsePeriod(options.period);
29
29
 
30
30
  const filters = options.filter
31
31
  ? options.filter.map((f: string) => {
@@ -65,7 +65,7 @@ export const statsCommand = new Command("stats")
65
65
  query(site, { metrics: ["visitors"], dimensions: ["country"], ...breakdownOpts }),
66
66
  query(site, { metrics: ["visitors"], dimensions: ["browser"], ...breakdownOpts }),
67
67
  query(site, { metrics: ["visitors"], dimensions: ["utm_source"], ...breakdownOpts }),
68
- listEvents(site, typeof dateRange === "string" ? dateRange : "30d", 10, options.test || false)
68
+ listEvents(site, dateRange, 10, options.test || false)
69
69
  );
70
70
  }
71
71
 
@@ -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 { coloredSparkline } from "../ui";
5
+ import { coloredSparkline, parsePeriod } from "../ui";
6
6
 
7
7
  export const trendCommand = new Command("trend")
8
8
  .description("Daily visitor trend")
@@ -25,7 +25,7 @@ export const trendCommand = new Command("trend")
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
  // Parse filters
31
31
  const filters = options.filter
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { annotationsCommand } from "./commands/annotations"
5
5
  import { completionsCommand } from "./commands/completions"
6
6
  import { countriesCommand } from "./commands/countries"
7
7
  import { eventsCommand } from "./commands/events"
8
+ import { funnelsCommand } from "./commands/funnels"
8
9
  import { initCommand } from "./commands/init"
9
10
  import { journeysCommand } from "./commands/journeys"
10
11
  import { loginCommand } from "./commands/login"
@@ -60,6 +61,7 @@ program.addCommand(countriesCommand)
60
61
  program.addCommand(trendCommand)
61
62
  program.addCommand(queryCommand)
62
63
  program.addCommand(eventsCommand)
64
+ program.addCommand(funnelsCommand)
63
65
  program.addCommand(journeysCommand)
64
66
  program.addCommand(realtimeCommand)
65
67
  program.addCommand(annotationsCommand)
package/src/ui.ts CHANGED
@@ -44,9 +44,25 @@ export function parsePeriod(period: string): string | [string, string] {
44
44
  const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
45
45
  return [yyyy(startOfLastMonth), yyyy(endOfLastMonth)];
46
46
  }
47
- default:
48
- // Return as-is for 7d, 30d, etc.
47
+ default: {
48
+ // Parse arbitrary periods: Nd (days), Nmo (months)
49
+ const daysMatch = period.match(/^(\d+)d$/i);
50
+ if (daysMatch) {
51
+ const days = parseInt(daysMatch[1]);
52
+ const start = new Date(today);
53
+ start.setDate(today.getDate() - days);
54
+ return [yyyy(start), yyyy(today)];
55
+ }
56
+ const moMatch = period.match(/^(\d+)mo$/i);
57
+ if (moMatch) {
58
+ const months = parseInt(moMatch[1]);
59
+ const start = new Date(today);
60
+ start.setMonth(today.getMonth() - months);
61
+ return [yyyy(start), yyyy(today)];
62
+ }
63
+ // "all" or API-recognized strings
49
64
  return period;
65
+ }
50
66
  }
51
67
  }
52
68