@supalytics/cli 0.3.8 → 0.4.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.
@@ -1,25 +1,24 @@
1
- import { Command } from "commander";
2
- import chalk from "chalk";
1
+ import chalk from "chalk"
2
+ import { Command } from "commander"
3
3
  import {
4
- listJourneys,
5
- getJourneyTimeline,
6
- getJourneyStats,
7
- formatRevenue,
8
- type VisitorTimelineAction,
9
- } from "../api";
10
- import { getDefaultSite } from "../config";
4
+ formatRevenue,
5
+ getJourneyStats,
6
+ getJourneyTimeline,
7
+ listJourneys,
8
+ type VisitorTimelineAction,
9
+ } from "../api"
10
+ import { getDefaultSite } from "../config"
11
11
  import {
12
- table,
13
- parsePeriod,
14
- formatDate,
15
- formatTimelineDate,
16
- formatTime,
17
- formatSessionDuration,
18
- type TableColumn,
19
- } from "../ui";
20
-
21
- const journeysDescription = `View visitor journeys and individual visitor details.
22
-
12
+ formatDate,
13
+ formatSessionDuration,
14
+ formatTime,
15
+ formatTimelineDate,
16
+ parsePeriod,
17
+ type TableColumn,
18
+ table,
19
+ } from "../ui"
20
+
21
+ const journeysExamples = `
23
22
  Examples:
24
23
  # List all visitors (last 30 days)
25
24
  supalytics journeys
@@ -40,203 +39,216 @@ Examples:
40
39
  supalytics journeys abc123xyz
41
40
 
42
41
  # JSON output
43
- supalytics journeys --json`;
42
+ supalytics journeys --json`
44
43
 
45
44
  export const journeysCommand = new Command("journeys")
46
- .description(journeysDescription)
47
- .argument("[visitor-id]", "Visitor ID to view details")
48
- .option("-s, --site <site>", "Site to query")
49
- .option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
50
- .option("--start <date>", "Start date (YYYY-MM-DD)")
51
- .option("--end <date>", "End date (YYYY-MM-DD)")
52
- .option("-f, --filter <filters...>", "Page-level filters (field:operator:value)")
53
- .option("--min-pageviews <n>", "Minimum pageviews", parseInt)
54
- .option("--max-pageviews <n>", "Maximum pageviews", parseInt)
55
- .option("--min-sessions <n>", "Minimum sessions", parseInt)
56
- .option("--max-sessions <n>", "Maximum sessions", parseInt)
57
- .option("--min-revenue <cents>", "Minimum revenue (cents)", parseInt)
58
- .option("--max-revenue <cents>", "Maximum revenue (cents)", parseInt)
59
- .option("--customers", "Only visitors with revenue")
60
- .option("--first-purchase", "Only first-time purchases")
61
- .option("--sort-by <field>", "Sort by: last_seen, first_seen, pageviews, sessions, revenue, latest_revenue", "last_seen")
62
- .option("--sort-order <order>", "Sort order: asc, desc", "desc")
63
- .option("-l, --limit <n>", "Results per page (max 100)", "50")
64
- .option("--offset <n>", "Pagination offset", "0")
65
- .option("--json", "Output as JSON")
66
- .option("-t, --test", "Test mode: query localhost data")
67
- .action(async (visitorId, options) => {
68
- const site = options.site || (await getDefaultSite());
69
-
70
- if (!site) {
71
- console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
72
- process.exit(1);
73
- }
74
-
75
- const dateRange = options.start && options.end
76
- ? [options.start, options.end] as [string, string]
77
- : parsePeriod(options.period);
78
-
79
- try {
80
- // If visitor ID provided, show visitor details
81
- if (visitorId) {
82
- await showVisitorDetails(site, visitorId, dateRange, options);
83
- return;
84
- }
85
-
86
- // Otherwise, list all visitors
87
- await listVisitors(site, dateRange, options);
88
- } catch (error) {
89
- console.error(chalk.red(`Error: ${(error as Error).message}`));
90
- process.exit(1);
91
- }
92
- });
45
+ .description("View visitor journeys and individual visitor details")
46
+ .addHelpText("after", journeysExamples)
47
+ .argument("[visitor-id]", "Visitor ID to view details")
48
+ .option("-s, --site <site>", "Site to query")
49
+ .option("-p, --period <period>", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
50
+ .option("--start <date>", "Start date (YYYY-MM-DD)")
51
+ .option("--end <date>", "End date (YYYY-MM-DD)")
52
+ .option("-f, --filter <filters...>", "Page-level filters (field:operator:value)")
53
+ .option("--min-pageviews <n>", "Minimum pageviews", parseInt)
54
+ .option("--max-pageviews <n>", "Maximum pageviews", parseInt)
55
+ .option("--min-sessions <n>", "Minimum sessions", parseInt)
56
+ .option("--max-sessions <n>", "Maximum sessions", parseInt)
57
+ .option("--min-revenue <cents>", "Minimum revenue (cents)", parseInt)
58
+ .option("--max-revenue <cents>", "Maximum revenue (cents)", parseInt)
59
+ .option("--customers", "Only visitors with revenue")
60
+ .option("--first-purchase", "Only first-time purchases")
61
+ .option(
62
+ "--sort-by <field>",
63
+ "Sort by: last_seen, first_seen, pageviews, sessions, revenue, latest_revenue",
64
+ "last_seen"
65
+ )
66
+ .option("--sort-order <order>", "Sort order: asc, desc", "desc")
67
+ .option("-l, --limit <n>", "Results per page (max 100)", "50")
68
+ .option("--offset <n>", "Pagination offset", "0")
69
+ .option("--json", "Output as JSON")
70
+ .option("-t, --test", "Test mode: query localhost data")
71
+ .action(async (visitorId, options) => {
72
+ const site = options.site || (await getDefaultSite())
73
+
74
+ if (!site) {
75
+ console.error(
76
+ chalk.red(
77
+ "Error: No site specified. Use --site or set a default with `supalytics login --site`"
78
+ )
79
+ )
80
+ process.exit(1)
81
+ }
82
+
83
+ const dateRange =
84
+ options.start && options.end
85
+ ? ([options.start, options.end] as [string, string])
86
+ : parsePeriod(options.period)
87
+
88
+ try {
89
+ // If visitor ID provided, show visitor details
90
+ if (visitorId) {
91
+ await showVisitorDetails(site, visitorId, dateRange, options)
92
+ return
93
+ }
94
+
95
+ // Otherwise, list all visitors
96
+ await listVisitors(site, dateRange, options)
97
+ } catch (error) {
98
+ console.error(chalk.red(`Error: ${(error as Error).message}`))
99
+ process.exit(1)
100
+ }
101
+ })
93
102
 
94
103
  // ============================================================================
95
104
  // LIST VISITORS
96
105
  // ============================================================================
97
106
 
98
107
  async function listVisitors(
99
- site: string,
100
- dateRange: string | [string, string],
101
- options: Record<string, unknown>
108
+ site: string,
109
+ dateRange: string | [string, string],
110
+ options: Record<string, unknown>
102
111
  ) {
103
- // Parse filters
104
- const filters = options.filter
105
- ? (options.filter as string[]).map((f: string) => {
106
- const parts = f.split(":");
107
- if (parts.length < 3) {
108
- console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`));
109
- process.exit(1);
110
- }
111
- const [field, operator, ...valueParts] = parts;
112
- return [field, operator, valueParts.join(":")] as [string, string, string];
113
- })
114
- : undefined;
115
-
116
- const response = await listJourneys(site, {
117
- date_range: dateRange,
118
- filters,
119
- pageviews_gt: options.minPageviews as number | undefined,
120
- pageviews_lt: options.maxPageviews as number | undefined,
121
- sessions_gt: options.minSessions as number | undefined,
122
- sessions_lt: options.maxSessions as number | undefined,
123
- revenue_gt: options.minRevenue as number | undefined,
124
- revenue_lt: options.maxRevenue as number | undefined,
125
- first_purchase: options.firstPurchase as boolean | undefined,
126
- has_revenue: options.customers as boolean | undefined,
127
- sort_by: options.sortBy as string,
128
- sort_order: options.sortOrder as "asc" | "desc",
129
- limit: parseInt(options.limit as string),
130
- offset: parseInt(options.offset as string),
131
- is_dev: (options.test as boolean) || false,
132
- });
133
-
134
- if (options.json) {
135
- console.log(JSON.stringify(response, null, 2));
136
- return;
137
- }
138
-
139
- const [startDate, endDate] = response.meta.date_range;
140
-
141
- console.log();
142
- console.log(chalk.bold("VISITOR JOURNEYS"), chalk.dim(`${startDate.split(" ")[0]} → ${endDate.split(" ")[0]}`));
143
- console.log();
144
-
145
- if (response.data.length === 0) {
146
- console.log(chalk.dim(" No visitors found"));
147
- return;
148
- }
149
-
150
- const columns: TableColumn[] = [
151
- {
152
- key: "visitor_id",
153
- label: "Visitor ID",
154
- align: "left",
155
- width: 36,
156
- format: (v) => chalk.cyan(String(v)),
157
- },
158
- {
159
- key: "country",
160
- label: "Country",
161
- align: "left",
162
- width: 7,
163
- },
164
- {
165
- key: "device",
166
- label: "Device",
167
- align: "left",
168
- width: 8,
169
- },
170
- {
171
- key: "first_referrer",
172
- label: "Source",
173
- align: "left",
174
- width: 18,
175
- format: (v) => {
176
- const source = String(v || "Direct");
177
- return source.length > 18 ? source.slice(0, 15) + "..." : source;
178
- },
179
- },
180
- {
181
- key: "entry_page",
182
- label: "Entry Page",
183
- align: "left",
184
- width: 20,
185
- format: (v) => {
186
- const page = String(v || "/");
187
- return page.length > 20 ? page.slice(0, 17) + "..." : page;
188
- },
189
- },
190
- {
191
- key: "pageview_count",
192
- label: "Views",
193
- align: "right",
194
- },
195
- {
196
- key: "session_count",
197
- label: "Sessions",
198
- align: "right",
199
- },
200
- {
201
- key: "total_revenue_cents",
202
- label: "Revenue",
203
- align: "right",
204
- format: (v) => {
205
- const cents = Number(v);
206
- return cents > 0 ? formatRevenue(cents) : chalk.dim("$0");
207
- },
208
- },
209
- {
210
- key: "first_seen",
211
- label: "First Seen",
212
- align: "left",
213
- width: 18,
214
- format: (v) => formatDate(String(v)),
215
- },
216
- {
217
- key: "last_seen",
218
- label: "Last Seen",
219
- align: "left",
220
- width: 18,
221
- format: (v) => formatDate(String(v)),
222
- },
223
- ];
224
-
225
- console.log(table(response.data, columns, { showHeader: true }));
226
- console.log();
227
-
228
- // Pagination info
229
- const { total, has_more, limit, offset } = response.pagination;
230
- if (total !== undefined) {
231
- console.log(chalk.dim(` ${response.data.length} visitors shown (${total} total)`));
232
- } else {
233
- console.log(chalk.dim(` ${response.data.length} visitors shown`));
234
- }
235
-
236
- if (has_more) {
237
- console.log(chalk.dim(` Use --offset ${offset + limit} for next page`));
238
- }
239
- console.log();
112
+ // Parse filters
113
+ const filters = options.filter
114
+ ? (options.filter as string[]).map((f: string) => {
115
+ const parts = f.split(":")
116
+ if (parts.length < 3) {
117
+ console.error(chalk.red(`Invalid filter format: ${f}. Use 'field:operator:value'`))
118
+ process.exit(1)
119
+ }
120
+ const [field, operator, ...valueParts] = parts
121
+ return [field, operator, valueParts.join(":")] as [string, string, string]
122
+ })
123
+ : undefined
124
+
125
+ const response = await listJourneys(site, {
126
+ date_range: dateRange,
127
+ filters,
128
+ pageviews_gt: options.minPageviews as number | undefined,
129
+ pageviews_lt: options.maxPageviews as number | undefined,
130
+ sessions_gt: options.minSessions as number | undefined,
131
+ sessions_lt: options.maxSessions as number | undefined,
132
+ revenue_gt: options.minRevenue as number | undefined,
133
+ revenue_lt: options.maxRevenue as number | undefined,
134
+ first_purchase: options.firstPurchase as boolean | undefined,
135
+ has_revenue: options.customers as boolean | undefined,
136
+ sort_by: options.sortBy as string,
137
+ sort_order: options.sortOrder as "asc" | "desc",
138
+ limit: parseInt(options.limit as string),
139
+ offset: parseInt(options.offset as string),
140
+ is_dev: (options.test as boolean) || false,
141
+ })
142
+
143
+ if (options.json) {
144
+ console.log(JSON.stringify(response, null, 2))
145
+ return
146
+ }
147
+
148
+ const [startDate, endDate] = response.meta.date_range
149
+
150
+ console.log()
151
+ console.log(
152
+ chalk.bold("VISITOR JOURNEYS"),
153
+ chalk.dim(`${startDate.split(" ")[0]} → ${endDate.split(" ")[0]}`)
154
+ )
155
+ console.log()
156
+
157
+ if (response.data.length === 0) {
158
+ console.log(chalk.dim(" No visitors found"))
159
+ return
160
+ }
161
+
162
+ const columns: TableColumn[] = [
163
+ {
164
+ key: "visitor_id",
165
+ label: "Visitor ID",
166
+ align: "left",
167
+ width: 36,
168
+ format: (v) => chalk.cyan(String(v)),
169
+ },
170
+ {
171
+ key: "country",
172
+ label: "Country",
173
+ align: "left",
174
+ width: 7,
175
+ },
176
+ {
177
+ key: "device",
178
+ label: "Device",
179
+ align: "left",
180
+ width: 8,
181
+ },
182
+ {
183
+ key: "first_referrer",
184
+ label: "Source",
185
+ align: "left",
186
+ width: 18,
187
+ format: (v) => {
188
+ const source = String(v || "Direct")
189
+ return source.length > 18 ? source.slice(0, 15) + "..." : source
190
+ },
191
+ },
192
+ {
193
+ key: "entry_page",
194
+ label: "Entry Page",
195
+ align: "left",
196
+ width: 20,
197
+ format: (v) => {
198
+ const page = String(v || "/")
199
+ return page.length > 20 ? page.slice(0, 17) + "..." : page
200
+ },
201
+ },
202
+ {
203
+ key: "pageview_count",
204
+ label: "Views",
205
+ align: "right",
206
+ },
207
+ {
208
+ key: "session_count",
209
+ label: "Sessions",
210
+ align: "right",
211
+ },
212
+ {
213
+ key: "total_revenue_cents",
214
+ label: "Revenue",
215
+ align: "right",
216
+ format: (v) => {
217
+ const cents = Number(v)
218
+ return cents > 0 ? formatRevenue(cents) : chalk.dim("$0")
219
+ },
220
+ },
221
+ {
222
+ key: "first_seen",
223
+ label: "First Seen",
224
+ align: "left",
225
+ width: 18,
226
+ format: (v) => formatDate(String(v)),
227
+ },
228
+ {
229
+ key: "last_seen",
230
+ label: "Last Seen",
231
+ align: "left",
232
+ width: 18,
233
+ format: (v) => formatDate(String(v)),
234
+ },
235
+ ]
236
+
237
+ console.log(table(response.data, columns, { showHeader: true }))
238
+ console.log()
239
+
240
+ // Pagination info
241
+ const { total, has_more, limit, offset } = response.pagination
242
+ if (total !== undefined) {
243
+ console.log(chalk.dim(` ${response.data.length} visitors shown (${total} total)`))
244
+ } else {
245
+ console.log(chalk.dim(` ${response.data.length} visitors shown`))
246
+ }
247
+
248
+ if (has_more) {
249
+ console.log(chalk.dim(` Use --offset ${offset + limit} for next page`))
250
+ }
251
+ console.log()
240
252
  }
241
253
 
242
254
  // ============================================================================
@@ -244,93 +256,107 @@ async function listVisitors(
244
256
  // ============================================================================
245
257
 
246
258
  async function showVisitorDetails(
247
- site: string,
248
- visitorId: string,
249
- dateRange: string | [string, string],
250
- options: Record<string, unknown>
259
+ site: string,
260
+ visitorId: string,
261
+ dateRange: string | [string, string],
262
+ options: Record<string, unknown>
251
263
  ) {
252
- // Fetch both stats and timeline
253
- const [statsResponse, timelineResponse] = await Promise.all([
254
- getJourneyStats(site, visitorId, (options.test as boolean) || false),
255
- getJourneyTimeline(site, {
256
- visitor_id: visitorId,
257
- date_range: dateRange,
258
- include_auto_events: true,
259
- limit: parseInt(options.limit as string),
260
- offset: parseInt(options.offset as string),
261
- is_dev: (options.test as boolean) || false,
262
- }),
263
- ]);
264
-
265
- if (options.json) {
266
- console.log(JSON.stringify({
267
- stats: statsResponse,
268
- timeline: timelineResponse,
269
- }, null, 2));
270
- return;
271
- }
272
-
273
- const stats = statsResponse.data;
274
- const timeline = timelineResponse.data;
275
-
276
- console.log();
277
- console.log(chalk.bold(`VISITOR: ${chalk.cyan(visitorId)}`));
278
- console.log();
279
-
280
- // Stats section
281
- console.log(chalk.bold("STATS"));
282
- console.log(` First Seen: ${formatDate(stats.first_seen)}`);
283
- console.log(` Last Seen: ${formatDate(stats.last_seen)}`);
284
- console.log(` Total Sessions: ${stats.total_sessions}`);
285
- console.log(` Total Pageviews: ${stats.total_pageviews}`);
286
- console.log(` Total Events: ${stats.total_events}`);
287
- console.log(` Total Revenue: ${stats.total_revenue_cents > 0 ? formatRevenue(stats.total_revenue_cents) : chalk.dim("$0")}`);
288
- console.log(` Avg Scroll Depth: ${stats.avg_scroll_depth.toFixed(0)}%`);
289
- console.log(` Avg Session: ${formatSessionDuration(stats.avg_session_duration_seconds)}`);
290
- console.log();
291
- console.log(` Location: ${[stats.city, stats.region, stats.country].filter(Boolean).join(", ")}`);
292
- console.log(` Device: ${stats.device} • ${stats.browser} • ${stats.os}`);
293
- console.log(` Source: ${stats.source}`);
294
- console.log(` Entry Page: ${stats.entry_page}`);
295
- if (stats.utm_campaign) {
296
- console.log(` UTM Campaign: ${stats.utm_campaign}`);
297
- }
298
- console.log();
299
-
300
- // Timeline section
301
- console.log(chalk.bold("TIMELINE"));
302
- console.log();
303
-
304
- if (timeline.length === 0) {
305
- console.log(chalk.dim(" No timeline data"));
306
- console.log();
307
- return;
308
- }
309
-
310
- // Group timeline by date and session
311
- const grouped = groupTimeline(timeline);
312
-
313
- for (const dateGroup of grouped) {
314
- console.log(chalk.dim(` ▼ ${dateGroup.date}`));
315
-
316
- for (const session of dateGroup.sessions) {
317
- if (session.isGrouped) {
318
- console.log(chalk.dim(` Session ${session.sessionNumber}`));
319
- }
320
-
321
- for (const action of session.actions) {
322
- console.log(formatTimelineAction(action));
323
- }
324
- }
325
-
326
- console.log();
327
- }
328
-
329
- // Pagination info
330
- if (timelineResponse.pagination.has_more) {
331
- console.log(chalk.dim(` ${timeline.length} items shown Use --offset ${timelineResponse.pagination.offset + timelineResponse.pagination.limit} for more`));
332
- console.log();
333
- }
264
+ // Fetch both stats and timeline
265
+ const [statsResponse, timelineResponse] = await Promise.all([
266
+ getJourneyStats(site, visitorId, (options.test as boolean) || false),
267
+ getJourneyTimeline(site, {
268
+ visitor_id: visitorId,
269
+ date_range: dateRange,
270
+ include_auto_events: true,
271
+ limit: parseInt(options.limit as string),
272
+ offset: parseInt(options.offset as string),
273
+ is_dev: (options.test as boolean) || false,
274
+ }),
275
+ ])
276
+
277
+ if (options.json) {
278
+ console.log(
279
+ JSON.stringify(
280
+ {
281
+ stats: statsResponse,
282
+ timeline: timelineResponse,
283
+ },
284
+ null,
285
+ 2
286
+ )
287
+ )
288
+ return
289
+ }
290
+
291
+ const stats = statsResponse.data
292
+ const timeline = timelineResponse.data
293
+
294
+ console.log()
295
+ console.log(chalk.bold(`VISITOR: ${chalk.cyan(visitorId)}`))
296
+ console.log()
297
+
298
+ // Stats section
299
+ console.log(chalk.bold("STATS"))
300
+ console.log(` First Seen: ${formatDate(stats.first_seen)}`)
301
+ console.log(` Last Seen: ${formatDate(stats.last_seen)}`)
302
+ console.log(` Total Sessions: ${stats.total_sessions}`)
303
+ console.log(` Total Pageviews: ${stats.total_pageviews}`)
304
+ console.log(` Total Events: ${stats.total_events}`)
305
+ console.log(
306
+ ` Total Revenue: ${stats.total_revenue_cents > 0 ? formatRevenue(stats.total_revenue_cents) : chalk.dim("$0")}`
307
+ )
308
+ console.log(` Avg Scroll Depth: ${stats.avg_scroll_depth.toFixed(0)}%`)
309
+ console.log(` Avg Session: ${formatSessionDuration(stats.avg_session_duration_seconds)}`)
310
+ console.log()
311
+ console.log(
312
+ ` Location: ${[stats.city, stats.region, stats.country].filter(Boolean).join(", ")}`
313
+ )
314
+ console.log(` Device: ${stats.device} • ${stats.browser} • ${stats.os}`)
315
+ console.log(` Source: ${stats.source}`)
316
+ console.log(` Entry Page: ${stats.entry_page}`)
317
+ if (stats.utm_campaign) {
318
+ console.log(` UTM Campaign: ${stats.utm_campaign}`)
319
+ }
320
+ console.log()
321
+
322
+ // Timeline section
323
+ console.log(chalk.bold("TIMELINE"))
324
+ console.log()
325
+
326
+ if (timeline.length === 0) {
327
+ console.log(chalk.dim(" No timeline data"))
328
+ console.log()
329
+ return
330
+ }
331
+
332
+ // Group timeline by date and session
333
+ const grouped = groupTimeline(timeline)
334
+
335
+ for (const dateGroup of grouped) {
336
+ console.log(chalk.dim(` ▼ ${dateGroup.date}`))
337
+
338
+ for (const session of dateGroup.sessions) {
339
+ if (session.isGrouped) {
340
+ console.log(chalk.dim(` Session ${session.sessionNumber}`))
341
+ }
342
+
343
+ for (const action of session.actions) {
344
+ console.log(formatTimelineAction(action))
345
+ }
346
+ }
347
+
348
+ console.log()
349
+ }
350
+
351
+ // Pagination info
352
+ if (timelineResponse.pagination.has_more) {
353
+ console.log(
354
+ chalk.dim(
355
+ ` ${timeline.length} items shown • Use --offset ${timelineResponse.pagination.offset + timelineResponse.pagination.limit} for more`
356
+ )
357
+ )
358
+ console.log()
359
+ }
334
360
  }
335
361
 
336
362
  // ============================================================================
@@ -338,133 +364,135 @@ async function showVisitorDetails(
338
364
  // ============================================================================
339
365
 
340
366
  interface TimelineSession {
341
- sessionNumber: number;
342
- sessionId: string;
343
- isGrouped: boolean;
344
- actions: VisitorTimelineAction[];
367
+ sessionNumber: number
368
+ sessionId: string
369
+ isGrouped: boolean
370
+ actions: VisitorTimelineAction[]
345
371
  }
346
372
 
347
373
  interface TimelineDateGroup {
348
- date: string;
349
- sessions: TimelineSession[];
374
+ date: string
375
+ sessions: TimelineSession[]
350
376
  }
351
377
 
352
378
  function groupTimeline(timeline: VisitorTimelineAction[]): TimelineDateGroup[] {
353
- const dateGroups = new Map<string, VisitorTimelineAction[]>();
354
-
355
- // Group by date
356
- for (const action of timeline) {
357
- const date = formatTimelineDate(action.timestamp);
358
- if (!dateGroups.has(date)) {
359
- dateGroups.set(date, []);
360
- }
361
- dateGroups.get(date)!.push(action);
362
- }
363
-
364
- // Convert to array and group by session within each date
365
- const result: TimelineDateGroup[] = [];
366
-
367
- for (const [date, actions] of dateGroups) {
368
- const sessionGroups = new Map<string, VisitorTimelineAction[]>();
369
- const nonSessionActions: VisitorTimelineAction[] = [];
370
-
371
- for (const action of actions) {
372
- if (action.type === "pageview" && action.session_id) {
373
- if (!sessionGroups.has(action.session_id)) {
374
- sessionGroups.set(action.session_id, []);
375
- }
376
- sessionGroups.get(action.session_id)!.push(action);
377
- } else {
378
- nonSessionActions.push(action);
379
- }
380
- }
381
-
382
- const sessions: TimelineSession[] = [];
383
- let sessionNumber = 1;
384
-
385
- // Add session-grouped actions
386
- for (const [sessionId, sessionActions] of sessionGroups) {
387
- sessions.push({
388
- sessionNumber: sessionNumber++,
389
- sessionId,
390
- isGrouped: true,
391
- actions: sessionActions,
392
- });
393
- }
394
-
395
- // Add non-session actions
396
- if (nonSessionActions.length > 0) {
397
- sessions.push({
398
- sessionNumber: 0,
399
- sessionId: "",
400
- isGrouped: false,
401
- actions: nonSessionActions,
402
- });
403
- }
404
-
405
- result.push({ date, sessions });
406
- }
407
-
408
- return result;
379
+ const dateGroups = new Map<string, VisitorTimelineAction[]>()
380
+
381
+ // Group by date
382
+ for (const action of timeline) {
383
+ const date = formatTimelineDate(action.timestamp)
384
+ if (!dateGroups.has(date)) {
385
+ dateGroups.set(date, [])
386
+ }
387
+ dateGroups.get(date)!.push(action)
388
+ }
389
+
390
+ // Convert to array and group by session within each date
391
+ const result: TimelineDateGroup[] = []
392
+
393
+ for (const [date, actions] of dateGroups) {
394
+ const sessionGroups = new Map<string, VisitorTimelineAction[]>()
395
+ const nonSessionActions: VisitorTimelineAction[] = []
396
+
397
+ for (const action of actions) {
398
+ if (action.type === "pageview" && action.session_id) {
399
+ if (!sessionGroups.has(action.session_id)) {
400
+ sessionGroups.set(action.session_id, [])
401
+ }
402
+ sessionGroups.get(action.session_id)!.push(action)
403
+ } else {
404
+ nonSessionActions.push(action)
405
+ }
406
+ }
407
+
408
+ const sessions: TimelineSession[] = []
409
+ let sessionNumber = 1
410
+
411
+ // Add session-grouped actions
412
+ for (const [sessionId, sessionActions] of sessionGroups) {
413
+ sessions.push({
414
+ sessionNumber: sessionNumber++,
415
+ sessionId,
416
+ isGrouped: true,
417
+ actions: sessionActions,
418
+ })
419
+ }
420
+
421
+ // Add non-session actions
422
+ if (nonSessionActions.length > 0) {
423
+ sessions.push({
424
+ sessionNumber: 0,
425
+ sessionId: "",
426
+ isGrouped: false,
427
+ actions: nonSessionActions,
428
+ })
429
+ }
430
+
431
+ result.push({ date, sessions })
432
+ }
433
+
434
+ return result
409
435
  }
410
436
 
411
437
  function formatTimelineAction(action: VisitorTimelineAction): string {
412
- const time = formatTime(action.timestamp);
413
- const indent = " ";
414
-
415
- if (action.type === "pageview") {
416
- const url = action.url_path || "/";
417
- const badges: string[] = [];
418
-
419
- if (action.is_entry_page === 1) {
420
- badges.push(chalk.dim("[Entry]"));
421
- }
422
-
423
- if (action.referrer) {
424
- badges.push(chalk.dim(`[${action.referrer}]`));
425
- }
426
-
427
- if (action.utm_source) {
428
- badges.push(chalk.dim(`[utm: ${action.utm_source}]`));
429
- }
430
-
431
- if (action.scroll_depth !== undefined && action.scroll_depth !== null) {
432
- badges.push(chalk.dim(`Scroll: ${action.scroll_depth}%`));
433
- }
434
-
435
- const badgeStr = badges.length > 0 ? " " + badges.join(" ") : "";
436
-
437
- return `${indent}${chalk.dim(time)} ${chalk.bold("PAGEVIEW")} ${chalk.cyan(url)}${badgeStr}`;
438
- }
439
-
440
- if (action.type === "event" || action.type === "auto") {
441
- const eventName = action.event_name || "unknown";
442
- const label = action.type === "auto" ? "AUTO" : "EVENT";
443
- let propsStr = "";
444
-
445
- if (action.event_properties) {
446
- try {
447
- const props = JSON.parse(action.event_properties);
448
- const pairs = Object.entries(props)
449
- .slice(0, 2)
450
- .map(([k, v]) => `${k}: ${v}`)
451
- .join(", ");
452
- propsStr = chalk.dim(` { ${pairs} }`);
453
- } catch {
454
- // Ignore parse errors
455
- }
456
- }
457
-
458
- return `${indent}${chalk.dim(time)} ${chalk.bold(label.padEnd(9))} ${eventName}${propsStr}`;
459
- }
460
-
461
- if (action.type === "revenue") {
462
- const amount = action.amount_cents ? formatRevenue(action.amount_cents, action.currency) : "$0";
463
- const revenueType = action.revenue_event || "payment";
464
- const txn = action.transaction_id ? chalk.dim(` (txn: ${action.transaction_id.slice(0, 12)})`) : "";
465
-
466
- return `${indent}${chalk.dim(time)} ${chalk.bold("REVENUE")} ${revenueType.padEnd(15)} ${amount}${txn}`;
467
- }
468
-
469
- return `${indent}${chalk.dim(time)} ${action.type}`;
438
+ const time = formatTime(action.timestamp)
439
+ const indent = " "
440
+
441
+ if (action.type === "pageview") {
442
+ const url = action.url_path || "/"
443
+ const badges: string[] = []
444
+
445
+ if (action.is_entry_page === 1) {
446
+ badges.push(chalk.dim("[Entry]"))
447
+ }
448
+
449
+ if (action.referrer) {
450
+ badges.push(chalk.dim(`[${action.referrer}]`))
451
+ }
452
+
453
+ if (action.utm_source) {
454
+ badges.push(chalk.dim(`[utm: ${action.utm_source}]`))
455
+ }
456
+
457
+ if (action.scroll_depth !== undefined && action.scroll_depth !== null) {
458
+ badges.push(chalk.dim(`Scroll: ${action.scroll_depth}%`))
459
+ }
460
+
461
+ const badgeStr = badges.length > 0 ? " " + badges.join(" ") : ""
462
+
463
+ return `${indent}${chalk.dim(time)} ${chalk.bold("PAGEVIEW")} ${chalk.cyan(url)}${badgeStr}`
464
+ }
465
+
466
+ if (action.type === "event" || action.type === "auto") {
467
+ const eventName = action.event_name || "unknown"
468
+ const label = action.type === "auto" ? "AUTO" : "EVENT"
469
+ let propsStr = ""
470
+
471
+ if (action.event_properties) {
472
+ try {
473
+ const props = JSON.parse(action.event_properties)
474
+ const pairs = Object.entries(props)
475
+ .slice(0, 2)
476
+ .map(([k, v]) => `${k}: ${v}`)
477
+ .join(", ")
478
+ propsStr = chalk.dim(` { ${pairs} }`)
479
+ } catch {
480
+ // Ignore parse errors
481
+ }
482
+ }
483
+
484
+ return `${indent}${chalk.dim(time)} ${chalk.bold(label.padEnd(9))} ${eventName}${propsStr}`
485
+ }
486
+
487
+ if (action.type === "revenue") {
488
+ const amount = action.amount_cents ? formatRevenue(action.amount_cents, action.currency) : "$0"
489
+ const revenueType = action.revenue_event || "payment"
490
+ const txn = action.transaction_id
491
+ ? chalk.dim(` (txn: ${action.transaction_id.slice(0, 12)})`)
492
+ : ""
493
+
494
+ return `${indent}${chalk.dim(time)} ${chalk.bold("REVENUE")} ${revenueType.padEnd(15)} ${amount}${txn}`
495
+ }
496
+
497
+ return `${indent}${chalk.dim(time)} ${action.type}`
470
498
  }