@supalytics/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/bin/supalytics +2 -0
- package/package.json +37 -0
- package/src/api.ts +311 -0
- package/src/commands/countries.ts +93 -0
- package/src/commands/events.ts +133 -0
- package/src/commands/login.ts +83 -0
- package/src/commands/logout.ts +15 -0
- package/src/commands/pages.ts +93 -0
- package/src/commands/query.ts +162 -0
- package/src/commands/realtime.ts +110 -0
- package/src/commands/referrers.ts +95 -0
- package/src/commands/sites.ts +65 -0
- package/src/commands/stats.ts +104 -0
- package/src/commands/trend.ts +95 -0
- package/src/config.ts +96 -0
- package/src/index.ts +79 -0
- package/src/ui.ts +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Supalytics CLI
|
|
2
|
+
|
|
3
|
+
Command-line interface for [Supalytics](https://supalytics.co) web analytics.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
░█▀▀░█░█░█▀█░█▀█░█░░░█░█░▀█▀░▀█▀░█▀▀░█▀▀
|
|
7
|
+
░▀▀█░█░█░█▀▀░█▀█░█░░░░█░░░█░░░█░░█░░░▀▀█
|
|
8
|
+
░▀▀▀░▀▀▀░▀░░░▀░▀░▀▀▀░░▀░░░▀░░▀▀▀░▀▀▀░▀▀▀
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- [Bun](https://bun.sh) runtime
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add -g @supalytics/cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
Get your API key from [supalytics.co/settings/api](https://supalytics.co/settings/api), then:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
supalytics login <your-api-key>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Quick Stats
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
supalytics stats # Last 30 days (default)
|
|
35
|
+
supalytics stats today
|
|
36
|
+
supalytics stats yesterday
|
|
37
|
+
supalytics stats week
|
|
38
|
+
supalytics stats month
|
|
39
|
+
supalytics stats 7d
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Realtime
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
supalytics realtime # Current visitors
|
|
46
|
+
supalytics realtime --watch # Auto-refresh every 30s
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Breakdowns
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
supalytics pages # Top pages
|
|
53
|
+
supalytics referrers # Top referrers
|
|
54
|
+
supalytics countries # Traffic by country
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Custom Queries
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Top pages with revenue
|
|
61
|
+
supalytics query -d page -m visitors,revenue
|
|
62
|
+
|
|
63
|
+
# Traffic by country and device
|
|
64
|
+
supalytics query -d country,device -m visitors
|
|
65
|
+
|
|
66
|
+
# Filter by country
|
|
67
|
+
supalytics query -d page -f "country:is:US"
|
|
68
|
+
|
|
69
|
+
# UTM campaign performance
|
|
70
|
+
supalytics query -d utm_source,utm_campaign -m visitors,revenue
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Events
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
supalytics events # List all events
|
|
77
|
+
supalytics events signup # Show properties for event
|
|
78
|
+
supalytics events signup --property plan # Breakdown by property
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Options
|
|
82
|
+
|
|
83
|
+
All commands support:
|
|
84
|
+
|
|
85
|
+
- `-s, --site <domain>` - Query a specific site
|
|
86
|
+
- `--json` - Output raw JSON
|
|
87
|
+
- `--no-revenue` - Exclude revenue metrics
|
|
88
|
+
- `-f, --filter <filter>` - Filter data (format: `field:operator:value`)
|
|
89
|
+
|
|
90
|
+
### Filters
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
-f "country:is:US"
|
|
94
|
+
-f "page:contains:/blog"
|
|
95
|
+
-f "device:is:mobile"
|
|
96
|
+
-f "referrer:is:twitter.com"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Multi-site Support
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
supalytics login <api-key> # Add a site
|
|
103
|
+
supalytics sites # List all sites
|
|
104
|
+
supalytics default <domain> # Set default site
|
|
105
|
+
supalytics stats -s other.com # Query specific site
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
Apache-2.0
|
package/bin/supalytics
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@supalytics/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Supalytics web analytics",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"supalytics": "./bin/supalytics"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"bin"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "bun run src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"analytics",
|
|
18
|
+
"web-analytics",
|
|
19
|
+
"cli",
|
|
20
|
+
"supalytics",
|
|
21
|
+
"privacy"
|
|
22
|
+
],
|
|
23
|
+
"author": "Supalytics",
|
|
24
|
+
"license": "Apache-2.0",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/yogesharc/supalytics-cli"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://supalytics.co",
|
|
30
|
+
"engines": {
|
|
31
|
+
"bun": ">=1.0.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"chalk": "^5.6.2",
|
|
35
|
+
"commander": "^14.0.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { getApiKeyForSite, getSites } from "./config";
|
|
2
|
+
|
|
3
|
+
const API_BASE = process.env.SUPALYTICS_API_URL || "https://api.supalytics.co";
|
|
4
|
+
|
|
5
|
+
export interface QueryRequest {
|
|
6
|
+
metrics: string[];
|
|
7
|
+
date_range?: string | [string, string];
|
|
8
|
+
dimensions?: string[];
|
|
9
|
+
filters?: [string, string, string][]; // [field, operator, value]
|
|
10
|
+
sort?: { field: string; order: "asc" | "desc" };
|
|
11
|
+
timezone?: string;
|
|
12
|
+
limit?: number;
|
|
13
|
+
offset?: number;
|
|
14
|
+
include_revenue?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface QueryResult {
|
|
18
|
+
dimensions: Record<string, string>;
|
|
19
|
+
metrics: Record<string, number>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface QueryResponse {
|
|
23
|
+
data: QueryResult[];
|
|
24
|
+
meta: {
|
|
25
|
+
domain: string;
|
|
26
|
+
date_range: [string, string];
|
|
27
|
+
timezone: string;
|
|
28
|
+
query_ms: number;
|
|
29
|
+
};
|
|
30
|
+
pagination: {
|
|
31
|
+
limit: number;
|
|
32
|
+
offset: number;
|
|
33
|
+
has_more: boolean;
|
|
34
|
+
};
|
|
35
|
+
query: {
|
|
36
|
+
metrics: string[];
|
|
37
|
+
dimensions?: string[];
|
|
38
|
+
date_range: string | [string, string];
|
|
39
|
+
filters?: [string, string, string][];
|
|
40
|
+
sort?: { field: string; order: "asc" | "desc" };
|
|
41
|
+
timezone: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ApiError {
|
|
46
|
+
error: string;
|
|
47
|
+
message?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Query analytics data
|
|
52
|
+
*/
|
|
53
|
+
export async function query(
|
|
54
|
+
site: string,
|
|
55
|
+
request: QueryRequest
|
|
56
|
+
): Promise<QueryResponse> {
|
|
57
|
+
const apiKey = await getApiKeyForSite(site);
|
|
58
|
+
|
|
59
|
+
if (!apiKey) {
|
|
60
|
+
const sites = await getSites();
|
|
61
|
+
if (sites.length === 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"No sites configured. Run `supalytics login <api-key>` to add a site."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
throw new Error(
|
|
67
|
+
`No API key found for '${site}'. Available sites: ${sites.join(", ")}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const response = await fetch(`${API_BASE}/v1/query`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `Bearer ${apiKey}`,
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(request),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const error = (await response.json()) as ApiError;
|
|
82
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return response.json();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatNumber(num: number): string {
|
|
89
|
+
if (num >= 1_000_000) {
|
|
90
|
+
return (num / 1_000_000).toFixed(1) + "M";
|
|
91
|
+
}
|
|
92
|
+
if (num >= 1_000) {
|
|
93
|
+
return (num / 1_000).toFixed(1) + "K";
|
|
94
|
+
}
|
|
95
|
+
return num.toString();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatDuration(seconds: number): string {
|
|
99
|
+
if (seconds < 60) {
|
|
100
|
+
return `${Math.round(seconds)}s`;
|
|
101
|
+
}
|
|
102
|
+
const mins = Math.floor(seconds / 60);
|
|
103
|
+
const secs = Math.round(seconds % 60);
|
|
104
|
+
return `${mins}m ${secs}s`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatPercent(value: number): string {
|
|
108
|
+
return `${value.toFixed(1)}%`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Events API types
|
|
112
|
+
export interface EventItem {
|
|
113
|
+
name: string;
|
|
114
|
+
count: number;
|
|
115
|
+
visitors: number;
|
|
116
|
+
has_properties: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface EventsResponse {
|
|
120
|
+
data: EventItem[];
|
|
121
|
+
meta: {
|
|
122
|
+
domain: string;
|
|
123
|
+
date_range: [string, string];
|
|
124
|
+
query_ms: number;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PropertyKeysResponse {
|
|
129
|
+
data: string[];
|
|
130
|
+
meta: {
|
|
131
|
+
domain: string;
|
|
132
|
+
event: string;
|
|
133
|
+
date_range: [string, string];
|
|
134
|
+
query_ms: number;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PropertyValue {
|
|
139
|
+
value: string;
|
|
140
|
+
count: number;
|
|
141
|
+
visitors: number;
|
|
142
|
+
revenue: number | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface PropertyBreakdownResponse {
|
|
146
|
+
data: PropertyValue[];
|
|
147
|
+
meta: {
|
|
148
|
+
domain: string;
|
|
149
|
+
event: string;
|
|
150
|
+
property: string;
|
|
151
|
+
date_range: [string, string];
|
|
152
|
+
query_ms: number;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* List all events
|
|
158
|
+
*/
|
|
159
|
+
export async function listEvents(
|
|
160
|
+
site: string,
|
|
161
|
+
period: string = "30d",
|
|
162
|
+
limit: number = 100
|
|
163
|
+
): Promise<EventsResponse> {
|
|
164
|
+
const apiKey = await getApiKeyForSite(site);
|
|
165
|
+
|
|
166
|
+
if (!apiKey) {
|
|
167
|
+
const sites = await getSites();
|
|
168
|
+
if (sites.length === 0) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
"No sites configured. Run `supalytics login <api-key>` to add a site."
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
throw new Error(
|
|
174
|
+
`No API key found for '${site}'. Available sites: ${sites.join(", ")}`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const params = new URLSearchParams({ period, limit: String(limit) });
|
|
179
|
+
const response = await fetch(`${API_BASE}/v1/events?${params}`, {
|
|
180
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
const error = (await response.json()) as ApiError;
|
|
185
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return response.json();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get property keys for an event
|
|
193
|
+
*/
|
|
194
|
+
export async function getEventProperties(
|
|
195
|
+
site: string,
|
|
196
|
+
eventName: string,
|
|
197
|
+
period: string = "30d"
|
|
198
|
+
): Promise<PropertyKeysResponse> {
|
|
199
|
+
const apiKey = await getApiKeyForSite(site);
|
|
200
|
+
|
|
201
|
+
if (!apiKey) {
|
|
202
|
+
throw new Error(`No API key found for '${site}'.`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const params = new URLSearchParams({ period });
|
|
206
|
+
const response = await fetch(
|
|
207
|
+
`${API_BASE}/v1/events/${encodeURIComponent(eventName)}/properties?${params}`,
|
|
208
|
+
{ headers: { Authorization: `Bearer ${apiKey}` } }
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
const error = (await response.json()) as ApiError;
|
|
213
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return response.json();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get property value breakdown
|
|
221
|
+
*/
|
|
222
|
+
export async function getPropertyBreakdown(
|
|
223
|
+
site: string,
|
|
224
|
+
eventName: string,
|
|
225
|
+
propertyKey: string,
|
|
226
|
+
period: string = "30d",
|
|
227
|
+
limit: number = 100,
|
|
228
|
+
includeRevenue: boolean = false
|
|
229
|
+
): Promise<PropertyBreakdownResponse> {
|
|
230
|
+
const apiKey = await getApiKeyForSite(site);
|
|
231
|
+
|
|
232
|
+
if (!apiKey) {
|
|
233
|
+
throw new Error(`No API key found for '${site}'.`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const params = new URLSearchParams({
|
|
237
|
+
period,
|
|
238
|
+
limit: String(limit),
|
|
239
|
+
include_revenue: String(includeRevenue),
|
|
240
|
+
});
|
|
241
|
+
const response = await fetch(
|
|
242
|
+
`${API_BASE}/v1/events/${encodeURIComponent(eventName)}/properties/${encodeURIComponent(propertyKey)}?${params}`,
|
|
243
|
+
{ headers: { Authorization: `Bearer ${apiKey}` } }
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const error = (await response.json()) as ApiError;
|
|
248
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return response.json();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Realtime API types
|
|
255
|
+
export interface RealtimePageItem {
|
|
256
|
+
page: string;
|
|
257
|
+
visitors: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface RealtimeCountryItem {
|
|
261
|
+
country: string;
|
|
262
|
+
visitors: number;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface RealtimeReferrerItem {
|
|
266
|
+
referrer: string;
|
|
267
|
+
visitors: number;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface RealtimeResponse {
|
|
271
|
+
data: {
|
|
272
|
+
active_visitors: number;
|
|
273
|
+
pages: RealtimePageItem[];
|
|
274
|
+
countries: RealtimeCountryItem[];
|
|
275
|
+
referrers: RealtimeReferrerItem[];
|
|
276
|
+
};
|
|
277
|
+
meta: {
|
|
278
|
+
domain: string;
|
|
279
|
+
timestamp: string;
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get realtime visitors
|
|
285
|
+
*/
|
|
286
|
+
export async function getRealtime(site: string): Promise<RealtimeResponse> {
|
|
287
|
+
const apiKey = await getApiKeyForSite(site);
|
|
288
|
+
|
|
289
|
+
if (!apiKey) {
|
|
290
|
+
const sites = await getSites();
|
|
291
|
+
if (sites.length === 0) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
"No sites configured. Run `supalytics login <api-key>` to add a site."
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
throw new Error(
|
|
297
|
+
`No API key found for '${site}'. Available sites: ${sites.join(", ")}`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const response = await fetch(`${API_BASE}/v1/realtime`, {
|
|
302
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (!response.ok) {
|
|
306
|
+
const error = (await response.json()) as ApiError;
|
|
307
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return response.json();
|
|
311
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { query, formatNumber } from "../api";
|
|
4
|
+
import { getDefaultSite } from "../config";
|
|
5
|
+
import { sparkBar, formatRevenue } from "../ui";
|
|
6
|
+
|
|
7
|
+
export const countriesCommand = new Command("countries")
|
|
8
|
+
.description("Traffic by country")
|
|
9
|
+
.option("-s, --site <site>", "Site to query")
|
|
10
|
+
.option("-p, --period <period>", "Time period (7d, 30d, 90d, 12mo, all)", "30d")
|
|
11
|
+
.option("--start <date>", "Start date (YYYY-MM-DD)")
|
|
12
|
+
.option("--end <date>", "End date (YYYY-MM-DD)")
|
|
13
|
+
.option("-l, --limit <number>", "Number of results", "10")
|
|
14
|
+
.option("-f, --filter <filters...>", "Filters in format 'field:operator:value'")
|
|
15
|
+
.option("--no-revenue", "Exclude revenue metrics")
|
|
16
|
+
.option("--json", "Output as JSON")
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
const site = options.site || (await getDefaultSite());
|
|
19
|
+
|
|
20
|
+
if (!site) {
|
|
21
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dateRange = options.start && options.end
|
|
26
|
+
? [options.start, options.end] as [string, string]
|
|
27
|
+
: options.period;
|
|
28
|
+
|
|
29
|
+
const filters = options.filter
|
|
30
|
+
? options.filter.map((f: string) => {
|
|
31
|
+
const parts = f.split(":");
|
|
32
|
+
if (parts.length < 3) {
|
|
33
|
+
console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const [field, operator, ...valueParts] = parts;
|
|
37
|
+
return [field, operator, valueParts.join(":")] as [string, string, string];
|
|
38
|
+
})
|
|
39
|
+
: undefined;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const metrics = ["visitors"];
|
|
43
|
+
if (options.revenue !== false) {
|
|
44
|
+
metrics.push("revenue");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const response = await query(site, {
|
|
48
|
+
metrics,
|
|
49
|
+
dimensions: ["country"],
|
|
50
|
+
filters,
|
|
51
|
+
date_range: dateRange,
|
|
52
|
+
limit: parseInt(options.limit),
|
|
53
|
+
include_revenue: options.revenue !== false,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (options.json) {
|
|
57
|
+
console.log(JSON.stringify(response, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [startDate, endDate] = response.meta.date_range;
|
|
62
|
+
|
|
63
|
+
console.log();
|
|
64
|
+
console.log(chalk.bold(" 🌍 Countries"), chalk.dim(`${startDate} → ${endDate}`));
|
|
65
|
+
console.log();
|
|
66
|
+
|
|
67
|
+
if (response.data.length === 0) {
|
|
68
|
+
console.log(chalk.dim(" No data"));
|
|
69
|
+
console.log();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const maxVisitors = Math.max(...response.data.map(r => r.metrics.visitors || 0), 1);
|
|
74
|
+
|
|
75
|
+
for (const result of response.data) {
|
|
76
|
+
const country = result.dimensions?.country || "Unknown";
|
|
77
|
+
const visitors = result.metrics.visitors || 0;
|
|
78
|
+
const bar = sparkBar(visitors, maxVisitors);
|
|
79
|
+
|
|
80
|
+
let line = ` ${country.padEnd(34)} ${bar} ${formatNumber(visitors).padStart(6)}`;
|
|
81
|
+
|
|
82
|
+
if (options.revenue !== false && result.metrics.revenue != null) {
|
|
83
|
+
line += ` ${formatRevenue(result.metrics.revenue)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(line);
|
|
87
|
+
}
|
|
88
|
+
console.log();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { listEvents, getEventProperties, getPropertyBreakdown, formatNumber, PropertyKeysResponse } from "../api";
|
|
4
|
+
import { getDefaultSite } from "../config";
|
|
5
|
+
|
|
6
|
+
const eventsDescription = `List and explore custom events.
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
# List all events
|
|
10
|
+
supalytics events
|
|
11
|
+
|
|
12
|
+
# List properties for an event
|
|
13
|
+
supalytics events signup
|
|
14
|
+
|
|
15
|
+
# Get breakdown of a property
|
|
16
|
+
supalytics events signup --property plan
|
|
17
|
+
|
|
18
|
+
# With revenue
|
|
19
|
+
supalytics events signup --property plan --revenue`;
|
|
20
|
+
|
|
21
|
+
function displayPropertyKeys(response: PropertyKeysResponse, event: string, json: boolean) {
|
|
22
|
+
if (json) {
|
|
23
|
+
console.log(JSON.stringify(response, null, 2));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log();
|
|
28
|
+
console.log(chalk.bold(`Properties for "${event}"`));
|
|
29
|
+
console.log();
|
|
30
|
+
|
|
31
|
+
if (response.data.length === 0) {
|
|
32
|
+
console.log(chalk.dim(" No properties found"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const key of response.data) {
|
|
37
|
+
console.log(` ${chalk.cyan(key)}`);
|
|
38
|
+
}
|
|
39
|
+
console.log();
|
|
40
|
+
console.log(chalk.dim(` Use: supalytics events ${event} --property <key>`));
|
|
41
|
+
console.log();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const eventsCommand = new Command("events")
|
|
45
|
+
.description(eventsDescription)
|
|
46
|
+
.argument("[event]", "Event name to explore")
|
|
47
|
+
.option("-s, --site <site>", "Site to query")
|
|
48
|
+
.option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
|
|
49
|
+
.option("--property <key>", "Get breakdown for a specific property")
|
|
50
|
+
.option("-l, --limit <number>", "Number of results", "20")
|
|
51
|
+
.option("--revenue", "Include revenue in property breakdown")
|
|
52
|
+
.option("--json", "Output as JSON")
|
|
53
|
+
.action(async (event, options) => {
|
|
54
|
+
const site = options.site || (await getDefaultSite());
|
|
55
|
+
|
|
56
|
+
if (!site) {
|
|
57
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// If no event specified, list all events
|
|
63
|
+
if (!event) {
|
|
64
|
+
const response = await listEvents(site, options.period, parseInt(options.limit));
|
|
65
|
+
|
|
66
|
+
if (options.json) {
|
|
67
|
+
console.log(JSON.stringify(response, null, 2));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const [startDate, endDate] = response.meta.date_range;
|
|
72
|
+
console.log();
|
|
73
|
+
console.log(chalk.bold("Events"), chalk.dim(`${startDate} → ${endDate}`));
|
|
74
|
+
console.log();
|
|
75
|
+
|
|
76
|
+
if (response.data.length === 0) {
|
|
77
|
+
console.log(chalk.dim(" No events found"));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const e of response.data) {
|
|
82
|
+
const props = e.has_properties ? chalk.dim(" [has properties]") : "";
|
|
83
|
+
console.log(` ${chalk.cyan(e.name)} ${formatNumber(e.visitors)} visitors ${formatNumber(e.count)} events${props}`);
|
|
84
|
+
}
|
|
85
|
+
console.log();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// If --property flag, get breakdown
|
|
90
|
+
if (options.property) {
|
|
91
|
+
const response = await getPropertyBreakdown(
|
|
92
|
+
site,
|
|
93
|
+
event,
|
|
94
|
+
options.property,
|
|
95
|
+
options.period,
|
|
96
|
+
parseInt(options.limit),
|
|
97
|
+
options.revenue
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (options.json) {
|
|
101
|
+
console.log(JSON.stringify(response, null, 2));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log();
|
|
106
|
+
console.log(chalk.bold(`${event}.${options.property}`), chalk.dim(`${response.meta.date_range[0]} → ${response.meta.date_range[1]}`));
|
|
107
|
+
console.log();
|
|
108
|
+
|
|
109
|
+
if (response.data.length === 0) {
|
|
110
|
+
console.log(chalk.dim(" No values found"));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const v of response.data) {
|
|
115
|
+
let line = ` ${chalk.cyan(v.value)} ${formatNumber(v.visitors)} visitors ${formatNumber(v.count)} events`;
|
|
116
|
+
if (options.revenue && v.revenue !== null) {
|
|
117
|
+
line += ` ${chalk.green("$" + (v.revenue / 100).toFixed(2))}`;
|
|
118
|
+
}
|
|
119
|
+
console.log(line);
|
|
120
|
+
}
|
|
121
|
+
console.log();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// If just event name, show properties
|
|
126
|
+
const response = await getEventProperties(site, event, options.period);
|
|
127
|
+
displayPropertyKeys(response, event, options.json);
|
|
128
|
+
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
});
|