@supalytics/cli 0.3.5 → 0.3.7
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 +414 -0
- package/src/commands/annotations.ts +139 -0
- package/src/commands/journeys.ts +470 -0
- package/src/index.ts +4 -0
- package/src/ui.ts +69 -0
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -327,3 +327,417 @@ export async function getRealtime(site: string, isDev: boolean = false): Promise
|
|
|
327
327
|
|
|
328
328
|
return response.json();
|
|
329
329
|
}
|
|
330
|
+
|
|
331
|
+
// Annotations API types
|
|
332
|
+
export interface AnnotationItem {
|
|
333
|
+
id: string;
|
|
334
|
+
date: string; // YYYY-MM-DD
|
|
335
|
+
title: string;
|
|
336
|
+
description?: string;
|
|
337
|
+
created_by: string;
|
|
338
|
+
created_at: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface AnnotationsResponse {
|
|
342
|
+
data: AnnotationItem[];
|
|
343
|
+
meta: {
|
|
344
|
+
domain: string;
|
|
345
|
+
date_range: [string | null, string | null];
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* List annotations for a site
|
|
351
|
+
*/
|
|
352
|
+
export async function listAnnotations(
|
|
353
|
+
site: string,
|
|
354
|
+
period: string = "30d"
|
|
355
|
+
): Promise<AnnotationsResponse> {
|
|
356
|
+
const apiKey = await getApiKeyForSite(site);
|
|
357
|
+
|
|
358
|
+
if (!apiKey) {
|
|
359
|
+
const sites = await getSites();
|
|
360
|
+
if (sites.length === 0) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
"No sites configured. Run `supalytics login` to authenticate."
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Not authenticated or site '${site}' not found. Available sites: ${sites.join(", ")}`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Valid periods
|
|
371
|
+
const validPeriods = ["7d", "14d", "30d", "90d", "12mo", "all"] as const;
|
|
372
|
+
if (!validPeriods.includes(period as typeof validPeriods[number])) {
|
|
373
|
+
throw new Error(`Invalid period "${period}". Valid periods: ${validPeriods.join(", ")}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Calculate date range from period
|
|
377
|
+
const endDate = new Date();
|
|
378
|
+
let startDate = new Date();
|
|
379
|
+
|
|
380
|
+
if (period === "7d") {
|
|
381
|
+
startDate.setDate(endDate.getDate() - 7);
|
|
382
|
+
} else if (period === "14d") {
|
|
383
|
+
startDate.setDate(endDate.getDate() - 14);
|
|
384
|
+
} else if (period === "30d") {
|
|
385
|
+
startDate.setDate(endDate.getDate() - 30);
|
|
386
|
+
} else if (period === "90d") {
|
|
387
|
+
startDate.setDate(endDate.getDate() - 90);
|
|
388
|
+
} else if (period === "12mo") {
|
|
389
|
+
startDate.setMonth(endDate.getMonth() - 12);
|
|
390
|
+
} else if (period === "all") {
|
|
391
|
+
startDate = new Date("2020-01-01");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const formatDate = (d: Date) => d.toISOString().split("T")[0];
|
|
395
|
+
const params = new URLSearchParams({
|
|
396
|
+
domain: site,
|
|
397
|
+
start_date: formatDate(startDate),
|
|
398
|
+
end_date: formatDate(endDate),
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const response = await fetch(`${API_BASE}/v1/annotations?${params}`, {
|
|
402
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
const error = (await response.json()) as ApiError;
|
|
407
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return response.json();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Create an annotation
|
|
415
|
+
*/
|
|
416
|
+
export async function createAnnotation(
|
|
417
|
+
site: string,
|
|
418
|
+
date: string,
|
|
419
|
+
title: string,
|
|
420
|
+
description?: string
|
|
421
|
+
): Promise<AnnotationItem> {
|
|
422
|
+
const apiKey = await getApiKeyForSite(site);
|
|
423
|
+
|
|
424
|
+
if (!apiKey) {
|
|
425
|
+
throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const response = await fetch(`${API_BASE}/v1/annotations`, {
|
|
429
|
+
method: "POST",
|
|
430
|
+
headers: {
|
|
431
|
+
Authorization: `Bearer ${apiKey}`,
|
|
432
|
+
"Content-Type": "application/json",
|
|
433
|
+
},
|
|
434
|
+
body: JSON.stringify({
|
|
435
|
+
domain: site,
|
|
436
|
+
date,
|
|
437
|
+
title,
|
|
438
|
+
description,
|
|
439
|
+
}),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
const error = (await response.json()) as ApiError;
|
|
444
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return response.json();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Delete an annotation
|
|
452
|
+
*/
|
|
453
|
+
export async function deleteAnnotation(
|
|
454
|
+
site: string,
|
|
455
|
+
annotationId: string
|
|
456
|
+
): Promise<void> {
|
|
457
|
+
const apiKey = await getApiKeyForSite(site);
|
|
458
|
+
|
|
459
|
+
if (!apiKey) {
|
|
460
|
+
throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const params = new URLSearchParams({ domain: site });
|
|
464
|
+
const response = await fetch(
|
|
465
|
+
`${API_BASE}/v1/annotations/${annotationId}?${params}`,
|
|
466
|
+
{
|
|
467
|
+
method: "DELETE",
|
|
468
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
469
|
+
}
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
if (!response.ok) {
|
|
473
|
+
const error = (await response.json()) as ApiError;
|
|
474
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Journeys API types
|
|
479
|
+
export interface VisitorListRow {
|
|
480
|
+
visitor_id: string;
|
|
481
|
+
country: string;
|
|
482
|
+
browser: string;
|
|
483
|
+
os: string;
|
|
484
|
+
device: string;
|
|
485
|
+
pageview_count: number;
|
|
486
|
+
session_count: number;
|
|
487
|
+
event_count: number;
|
|
488
|
+
total_revenue_cents: number;
|
|
489
|
+
first_seen: string; // "2024-01-15 10:30:45"
|
|
490
|
+
last_seen: string;
|
|
491
|
+
first_referrer: string; // "google.com" or "Direct"
|
|
492
|
+
entry_page: string; // "/pricing"
|
|
493
|
+
latest_revenue_date: string;
|
|
494
|
+
revenue_event_count: number;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export interface VisitorTimelineAction {
|
|
498
|
+
timestamp: string; // "2024-01-15 10:30:45"
|
|
499
|
+
type: "pageview" | "event" | "revenue" | "auto";
|
|
500
|
+
session_id: string;
|
|
501
|
+
url_path?: string;
|
|
502
|
+
referrer?: string;
|
|
503
|
+
utm_source?: string;
|
|
504
|
+
is_entry_page?: number; // 1 or 0
|
|
505
|
+
scroll_depth?: number;
|
|
506
|
+
event_name?: string;
|
|
507
|
+
event_properties?: string; // JSON string
|
|
508
|
+
transaction_id?: string;
|
|
509
|
+
amount_cents?: number;
|
|
510
|
+
currency?: string;
|
|
511
|
+
revenue_event?: string;
|
|
512
|
+
country?: string;
|
|
513
|
+
browser?: string;
|
|
514
|
+
os?: string;
|
|
515
|
+
device?: string;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export interface VisitorStats {
|
|
519
|
+
visitor_id: string;
|
|
520
|
+
first_seen: string;
|
|
521
|
+
last_seen: string;
|
|
522
|
+
total_sessions: number;
|
|
523
|
+
total_pageviews: number;
|
|
524
|
+
total_events: number;
|
|
525
|
+
total_revenue_cents: number;
|
|
526
|
+
latest_revenue_event?: string;
|
|
527
|
+
latest_revenue_amount?: number;
|
|
528
|
+
latest_revenue_date?: string;
|
|
529
|
+
avg_scroll_depth: number;
|
|
530
|
+
avg_session_duration_seconds: number;
|
|
531
|
+
country: string;
|
|
532
|
+
region: string;
|
|
533
|
+
city: string;
|
|
534
|
+
browser: string;
|
|
535
|
+
os: string;
|
|
536
|
+
device: string;
|
|
537
|
+
entry_page: string;
|
|
538
|
+
source: string;
|
|
539
|
+
utm_source?: string;
|
|
540
|
+
utm_medium?: string;
|
|
541
|
+
utm_campaign?: string;
|
|
542
|
+
utm_content?: string;
|
|
543
|
+
utm_term?: string;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export interface VisitorFilters {
|
|
547
|
+
pageviews_gt?: number;
|
|
548
|
+
pageviews_lt?: number;
|
|
549
|
+
pageviews_eq?: number;
|
|
550
|
+
sessions_gt?: number;
|
|
551
|
+
sessions_lt?: number;
|
|
552
|
+
sessions_eq?: number;
|
|
553
|
+
events_gt?: number;
|
|
554
|
+
events_lt?: number;
|
|
555
|
+
events_eq?: number;
|
|
556
|
+
revenue_gt?: number; // cents
|
|
557
|
+
revenue_lt?: number;
|
|
558
|
+
revenue_eq?: number;
|
|
559
|
+
first_time_conversion?: boolean;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export interface JourneysListRequest {
|
|
563
|
+
date_range?: string | [string, string];
|
|
564
|
+
filters?: [string, string, string][];
|
|
565
|
+
pageviews_gt?: number;
|
|
566
|
+
pageviews_lt?: number;
|
|
567
|
+
pageviews_eq?: number;
|
|
568
|
+
sessions_gt?: number;
|
|
569
|
+
sessions_lt?: number;
|
|
570
|
+
sessions_eq?: number;
|
|
571
|
+
events_gt?: number;
|
|
572
|
+
events_lt?: number;
|
|
573
|
+
events_eq?: number;
|
|
574
|
+
revenue_gt?: number;
|
|
575
|
+
revenue_lt?: number;
|
|
576
|
+
revenue_eq?: number;
|
|
577
|
+
first_purchase?: boolean;
|
|
578
|
+
has_revenue?: boolean;
|
|
579
|
+
sort_by?: string;
|
|
580
|
+
sort_order?: "asc" | "desc";
|
|
581
|
+
limit?: number;
|
|
582
|
+
offset?: number;
|
|
583
|
+
timezone?: string;
|
|
584
|
+
is_dev?: boolean;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export interface JourneysListResponse {
|
|
588
|
+
data: VisitorListRow[];
|
|
589
|
+
meta: {
|
|
590
|
+
domain: string;
|
|
591
|
+
date_range: [string, string];
|
|
592
|
+
timezone: string;
|
|
593
|
+
query_ms: number;
|
|
594
|
+
};
|
|
595
|
+
pagination: {
|
|
596
|
+
limit: number;
|
|
597
|
+
offset: number;
|
|
598
|
+
has_more: boolean;
|
|
599
|
+
total?: number;
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export interface JourneyTimelineRequest {
|
|
604
|
+
visitor_id: string;
|
|
605
|
+
date_range?: string | [string, string];
|
|
606
|
+
include_auto_events?: boolean;
|
|
607
|
+
limit?: number;
|
|
608
|
+
offset?: number;
|
|
609
|
+
timezone?: string;
|
|
610
|
+
is_dev?: boolean;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export interface JourneyTimelineResponse {
|
|
614
|
+
data: VisitorTimelineAction[];
|
|
615
|
+
meta: {
|
|
616
|
+
domain: string;
|
|
617
|
+
visitor_id: string;
|
|
618
|
+
date_range: [string, string];
|
|
619
|
+
timezone: string;
|
|
620
|
+
query_ms: number;
|
|
621
|
+
};
|
|
622
|
+
pagination: {
|
|
623
|
+
limit: number;
|
|
624
|
+
offset: number;
|
|
625
|
+
has_more: boolean;
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export interface JourneyStatsResponse {
|
|
630
|
+
data: VisitorStats;
|
|
631
|
+
meta: {
|
|
632
|
+
domain: string;
|
|
633
|
+
visitor_id: string;
|
|
634
|
+
query_ms: number;
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* List visitor journeys with filters
|
|
640
|
+
*/
|
|
641
|
+
export async function listJourneys(
|
|
642
|
+
site: string,
|
|
643
|
+
request: JourneysListRequest
|
|
644
|
+
): Promise<JourneysListResponse> {
|
|
645
|
+
const apiKey = await getApiKeyForSite(site);
|
|
646
|
+
|
|
647
|
+
if (!apiKey) {
|
|
648
|
+
const sites = await getSites();
|
|
649
|
+
if (sites.length === 0) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
"No sites configured. Run `supalytics login` to authenticate."
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
throw new Error(
|
|
655
|
+
`Not authenticated or site '${site}' not found. Available sites: ${sites.join(", ")}`
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const response = await fetch(`${API_BASE}/v1/journeys/list`, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
headers: {
|
|
662
|
+
Authorization: `Bearer ${apiKey}`,
|
|
663
|
+
"Content-Type": "application/json",
|
|
664
|
+
},
|
|
665
|
+
body: JSON.stringify({ ...request, domain: site }),
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
if (!response.ok) {
|
|
669
|
+
const error = (await response.json()) as ApiError;
|
|
670
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return response.json();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Get timeline for a specific visitor
|
|
678
|
+
*/
|
|
679
|
+
export async function getJourneyTimeline(
|
|
680
|
+
site: string,
|
|
681
|
+
request: JourneyTimelineRequest
|
|
682
|
+
): Promise<JourneyTimelineResponse> {
|
|
683
|
+
const apiKey = await getApiKeyForSite(site);
|
|
684
|
+
|
|
685
|
+
if (!apiKey) {
|
|
686
|
+
throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const response = await fetch(`${API_BASE}/v1/journeys/timeline`, {
|
|
690
|
+
method: "POST",
|
|
691
|
+
headers: {
|
|
692
|
+
Authorization: `Bearer ${apiKey}`,
|
|
693
|
+
"Content-Type": "application/json",
|
|
694
|
+
},
|
|
695
|
+
body: JSON.stringify({ ...request, domain: site }),
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
if (!response.ok) {
|
|
699
|
+
const error = (await response.json()) as ApiError;
|
|
700
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return response.json();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Get stats for a specific visitor
|
|
708
|
+
*/
|
|
709
|
+
export async function getJourneyStats(
|
|
710
|
+
site: string,
|
|
711
|
+
visitorId: string,
|
|
712
|
+
isDev: boolean = false
|
|
713
|
+
): Promise<JourneyStatsResponse> {
|
|
714
|
+
const apiKey = await getApiKeyForSite(site);
|
|
715
|
+
|
|
716
|
+
if (!apiKey) {
|
|
717
|
+
throw new Error(`Not authenticated. Run \`supalytics login\` first.`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const response = await fetch(`${API_BASE}/v1/journeys/stats`, {
|
|
721
|
+
method: "POST",
|
|
722
|
+
headers: {
|
|
723
|
+
Authorization: `Bearer ${apiKey}`,
|
|
724
|
+
"Content-Type": "application/json",
|
|
725
|
+
},
|
|
726
|
+
body: JSON.stringify({ visitor_id: visitorId, is_dev: isDev, domain: site }),
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
if (!response.ok) {
|
|
730
|
+
const error = (await response.json()) as ApiError;
|
|
731
|
+
throw new Error(error.message || error.error || `HTTP ${response.status}`);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return response.json();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Format revenue in cents to dollars
|
|
739
|
+
*/
|
|
740
|
+
export function formatRevenue(cents: number, currency: string = "USD"): string {
|
|
741
|
+
const dollars = cents / 100;
|
|
742
|
+
return `$${dollars.toFixed(2)}${currency !== "USD" ? ` ${currency}` : ""}`;
|
|
743
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { listAnnotations, createAnnotation, deleteAnnotation } from "../api";
|
|
4
|
+
import { getDefaultSite } from "../config";
|
|
5
|
+
|
|
6
|
+
const annotationsDescription = `Manage chart annotations.
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
# List all annotations
|
|
10
|
+
supalytics annotations
|
|
11
|
+
|
|
12
|
+
# List annotations for a specific period
|
|
13
|
+
supalytics annotations -p 90d
|
|
14
|
+
|
|
15
|
+
# Add an annotation
|
|
16
|
+
supalytics annotations add 2025-01-15 "Launched v2.0"
|
|
17
|
+
|
|
18
|
+
# Add an annotation with description
|
|
19
|
+
supalytics annotations add 2025-01-15 "Launched v2.0" -d "Major redesign with new dashboard"
|
|
20
|
+
|
|
21
|
+
# Remove an annotation
|
|
22
|
+
supalytics annotations remove <id>`;
|
|
23
|
+
|
|
24
|
+
export const annotationsCommand = new Command("annotations")
|
|
25
|
+
.description(annotationsDescription)
|
|
26
|
+
.argument("[period]", "Time period: 7d, 14d, 30d, 90d, 12mo, all", "30d")
|
|
27
|
+
.option("-s, --site <site>", "Site to query")
|
|
28
|
+
.option("--json", "Output as JSON")
|
|
29
|
+
.action(async (period, options) => {
|
|
30
|
+
const site = options.site || (await getDefaultSite());
|
|
31
|
+
|
|
32
|
+
if (!site) {
|
|
33
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const response = await listAnnotations(site, period);
|
|
39
|
+
|
|
40
|
+
if (options.json) {
|
|
41
|
+
console.log(JSON.stringify(response, null, 2));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const [startDate, endDate] = response.meta.date_range;
|
|
46
|
+
console.log();
|
|
47
|
+
console.log(chalk.bold("Annotations"), chalk.dim(`${startDate} → ${endDate}`));
|
|
48
|
+
console.log();
|
|
49
|
+
|
|
50
|
+
if (response.data.length === 0) {
|
|
51
|
+
console.log(chalk.dim(" No annotations found"));
|
|
52
|
+
console.log();
|
|
53
|
+
console.log(chalk.dim(" Add one with: supalytics annotations add <date> <title>"));
|
|
54
|
+
console.log();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const annotation of response.data) {
|
|
59
|
+
console.log(` ${chalk.cyan(annotation.date)} ${annotation.title}`);
|
|
60
|
+
if (annotation.description) {
|
|
61
|
+
console.log(` ${chalk.dim(annotation.description)}`);
|
|
62
|
+
}
|
|
63
|
+
console.log(chalk.dim(` id: ${annotation.id}`));
|
|
64
|
+
}
|
|
65
|
+
console.log();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Subcommand: add
|
|
73
|
+
annotationsCommand
|
|
74
|
+
.command("add <date> <title>")
|
|
75
|
+
.description("Add an annotation")
|
|
76
|
+
.option("-s, --site <site>", "Site to query")
|
|
77
|
+
.option("-d, --description <text>", "Optional description")
|
|
78
|
+
.option("--json", "Output as JSON")
|
|
79
|
+
.action(async (date, title, options) => {
|
|
80
|
+
const site = options.site || (await getDefaultSite());
|
|
81
|
+
|
|
82
|
+
if (!site) {
|
|
83
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate date format
|
|
88
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
89
|
+
console.error(chalk.red("Error: Date must be in YYYY-MM-DD format"));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const annotation = await createAnnotation(site, date, title, options.description);
|
|
95
|
+
|
|
96
|
+
if (options.json) {
|
|
97
|
+
console.log(JSON.stringify(annotation, null, 2));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log();
|
|
102
|
+
console.log(chalk.green("Annotation added"));
|
|
103
|
+
console.log();
|
|
104
|
+
console.log(` ${chalk.cyan(annotation.date)} ${annotation.title}`);
|
|
105
|
+
if (annotation.description) {
|
|
106
|
+
console.log(` ${chalk.dim(annotation.description)}`);
|
|
107
|
+
}
|
|
108
|
+
console.log(chalk.dim(` id: ${annotation.id}`));
|
|
109
|
+
console.log();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Subcommand: remove
|
|
117
|
+
annotationsCommand
|
|
118
|
+
.command("remove <id>")
|
|
119
|
+
.description("Remove an annotation")
|
|
120
|
+
.option("-s, --site <site>", "Site to query")
|
|
121
|
+
.action(async (id, options) => {
|
|
122
|
+
const site = options.site || (await getDefaultSite());
|
|
123
|
+
|
|
124
|
+
if (!site) {
|
|
125
|
+
console.error(chalk.red("Error: No site specified. Use --site or set a default with `supalytics login --site`"));
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await deleteAnnotation(site, id);
|
|
131
|
+
|
|
132
|
+
console.log();
|
|
133
|
+
console.log(chalk.green("Annotation removed"));
|
|
134
|
+
console.log();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error(chalk.red(`Error: ${(error as Error).message}`));
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import {
|
|
4
|
+
listJourneys,
|
|
5
|
+
getJourneyTimeline,
|
|
6
|
+
getJourneyStats,
|
|
7
|
+
formatRevenue,
|
|
8
|
+
type VisitorTimelineAction,
|
|
9
|
+
} from "../api";
|
|
10
|
+
import { getDefaultSite } from "../config";
|
|
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
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
# List all visitors (last 30 days)
|
|
25
|
+
supalytics journeys
|
|
26
|
+
|
|
27
|
+
# Only paying customers
|
|
28
|
+
supalytics journeys --customers
|
|
29
|
+
|
|
30
|
+
# High-value visitors (5+ pageviews, $50+ revenue)
|
|
31
|
+
supalytics journeys --min-pageviews 5 --min-revenue 5000
|
|
32
|
+
|
|
33
|
+
# Filter by country
|
|
34
|
+
supalytics journeys -f "country:is:US"
|
|
35
|
+
|
|
36
|
+
# Sort by revenue
|
|
37
|
+
supalytics journeys --sort-by revenue --customers
|
|
38
|
+
|
|
39
|
+
# View specific visitor journey
|
|
40
|
+
supalytics journeys abc123xyz
|
|
41
|
+
|
|
42
|
+
# JSON output
|
|
43
|
+
supalytics journeys --json`;
|
|
44
|
+
|
|
45
|
+
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
|
+
});
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// LIST VISITORS
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
async function listVisitors(
|
|
99
|
+
site: string,
|
|
100
|
+
dateRange: string | [string, string],
|
|
101
|
+
options: Record<string, unknown>
|
|
102
|
+
) {
|
|
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: 16,
|
|
156
|
+
format: (v) => chalk.cyan(String(v).slice(0, 12)),
|
|
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();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ============================================================================
|
|
243
|
+
// SHOW VISITOR DETAILS
|
|
244
|
+
// ============================================================================
|
|
245
|
+
|
|
246
|
+
async function showVisitorDetails(
|
|
247
|
+
site: string,
|
|
248
|
+
visitorId: string,
|
|
249
|
+
dateRange: string | [string, string],
|
|
250
|
+
options: Record<string, unknown>
|
|
251
|
+
) {
|
|
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.slice(0, 16))}`));
|
|
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
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ============================================================================
|
|
337
|
+
// HELPER FUNCTIONS
|
|
338
|
+
// ============================================================================
|
|
339
|
+
|
|
340
|
+
interface TimelineSession {
|
|
341
|
+
sessionNumber: number;
|
|
342
|
+
sessionId: string;
|
|
343
|
+
isGrouped: boolean;
|
|
344
|
+
actions: VisitorTimelineAction[];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
interface TimelineDateGroup {
|
|
348
|
+
date: string;
|
|
349
|
+
sessions: TimelineSession[];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
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;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
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}`;
|
|
470
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { queryCommand } from "./commands/query";
|
|
|
13
13
|
import { eventsCommand } from "./commands/events";
|
|
14
14
|
import { realtimeCommand } from "./commands/realtime";
|
|
15
15
|
import { completionsCommand } from "./commands/completions";
|
|
16
|
+
import { annotationsCommand } from "./commands/annotations";
|
|
17
|
+
import { journeysCommand } from "./commands/journeys";
|
|
16
18
|
import { initCommand } from "./commands/init";
|
|
17
19
|
import { updateCommand } from "./commands/update";
|
|
18
20
|
|
|
@@ -92,7 +94,9 @@ program.addCommand(countriesCommand);
|
|
|
92
94
|
program.addCommand(trendCommand);
|
|
93
95
|
program.addCommand(queryCommand);
|
|
94
96
|
program.addCommand(eventsCommand);
|
|
97
|
+
program.addCommand(journeysCommand);
|
|
95
98
|
program.addCommand(realtimeCommand);
|
|
99
|
+
program.addCommand(annotationsCommand);
|
|
96
100
|
program.addCommand(completionsCommand);
|
|
97
101
|
|
|
98
102
|
// Utility commands
|
package/src/ui.ts
CHANGED
|
@@ -188,3 +188,72 @@ export function table(
|
|
|
188
188
|
|
|
189
189
|
return lines.join("\n");
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Format a date for display
|
|
194
|
+
*/
|
|
195
|
+
export function formatDate(dateStr: string): string {
|
|
196
|
+
const date = new Date(dateStr.replace(" ", "T") + "Z");
|
|
197
|
+
const now = new Date();
|
|
198
|
+
const diffMs = now.getTime() - date.getTime();
|
|
199
|
+
const diffHours = diffMs / (1000 * 60 * 60);
|
|
200
|
+
|
|
201
|
+
if (diffHours < 1) {
|
|
202
|
+
const mins = Math.floor(diffMs / (1000 * 60));
|
|
203
|
+
return `${mins} minute${mins !== 1 ? "s" : ""} ago`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (diffHours < 24) {
|
|
207
|
+
const hours = Math.floor(diffHours);
|
|
208
|
+
return `${hours} hour${hours !== 1 ? "s" : ""} ago`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Format as "Jan 15, 10:30am"
|
|
212
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
213
|
+
const month = months[date.getMonth()];
|
|
214
|
+
const day = date.getDate();
|
|
215
|
+
const hour = date.getHours();
|
|
216
|
+
const minute = date.getMinutes();
|
|
217
|
+
const ampm = hour >= 12 ? "pm" : "am";
|
|
218
|
+
const displayHour = hour % 12 || 12;
|
|
219
|
+
const displayMinute = minute.toString().padStart(2, "0");
|
|
220
|
+
|
|
221
|
+
return `${month} ${day}, ${displayHour}:${displayMinute}${ampm}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Format a date as full date for timeline headers
|
|
226
|
+
*/
|
|
227
|
+
export function formatTimelineDate(dateStr: string): string {
|
|
228
|
+
const date = new Date(dateStr.replace(" ", "T") + "Z");
|
|
229
|
+
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
230
|
+
return `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Format time only (HH:MM:SS)
|
|
235
|
+
*/
|
|
236
|
+
export function formatTime(dateStr: string): string {
|
|
237
|
+
const date = new Date(dateStr.replace(" ", "T") + "Z");
|
|
238
|
+
const hour = date.getHours().toString().padStart(2, "0");
|
|
239
|
+
const minute = date.getMinutes().toString().padStart(2, "0");
|
|
240
|
+
const second = date.getSeconds().toString().padStart(2, "0");
|
|
241
|
+
return `${hour}:${minute}:${second}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Format session duration
|
|
246
|
+
*/
|
|
247
|
+
export function formatSessionDuration(seconds: number): string {
|
|
248
|
+
if (seconds < 60) {
|
|
249
|
+
return `${Math.round(seconds)}s`;
|
|
250
|
+
}
|
|
251
|
+
const mins = Math.floor(seconds / 60);
|
|
252
|
+
const secs = Math.round(seconds % 60);
|
|
253
|
+
if (mins < 60) {
|
|
254
|
+
return `${mins}m ${secs}s`;
|
|
255
|
+
}
|
|
256
|
+
const hours = Math.floor(mins / 60);
|
|
257
|
+
const remainingMins = mins % 60;
|
|
258
|
+
return `${hours}h ${remainingMins}m`;
|
|
259
|
+
}
|