@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 +1 -1
- package/src/api.ts +259 -36
- package/src/commands/annotations.ts +26 -8
- package/src/commands/completions.ts +20 -9
- package/src/commands/countries.ts +2 -2
- package/src/commands/events.ts +14 -4
- package/src/commands/funnels.ts +471 -0
- package/src/commands/pages.ts +2 -2
- package/src/commands/query.ts +2 -2
- package/src/commands/referrers.ts +2 -2
- package/src/commands/stats.ts +4 -4
- package/src/commands/trend.ts +2 -2
- package/src/index.ts +2 -0
- package/src/ui.ts +18 -2
package/package.json
CHANGED
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
|
-
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
84
|
-
const
|
|
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,
|
|
130
|
-
const site =
|
|
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="
|
|
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) => {
|
package/src/commands/events.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
150
|
-
getEventProperties(site, event,
|
|
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
|
+
})
|
package/src/commands/pages.ts
CHANGED
|
@@ -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) => {
|
package/src/commands/query.ts
CHANGED
|
@@ -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) => {
|
package/src/commands/stats.ts
CHANGED
|
@@ -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 (
|
|
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,
|
|
68
|
+
listEvents(site, dateRange, 10, options.test || false)
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
|
package/src/commands/trend.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|