@coursebuilder/analytics 1.1.1 → 1.1.2-canary.0.0c9beb2ae
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/dist/components/index.d.ts +17 -1
- package/dist/components/index.js +110 -7
- package/dist/components/index.js.map +1 -1
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/providers/database.d.ts +30 -0
- package/dist/providers/database.js +59 -0
- package/dist/providers/database.js.map +1 -1
- package/dist/providers/index.js +59 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/types.d.ts +39 -1
- package/package.json +4 -4
- package/src/components/omnibus-dashboard.tsx +177 -6
- package/src/engine.ts +3 -2
- package/src/providers/database.ts +68 -0
- package/src/types.ts +49 -0
package/dist/types.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ type Percentage = Brand<number, 'Percentage'>;
|
|
|
8
8
|
type Minutes = Brand<number, 'Minutes'>;
|
|
9
9
|
type Seconds = Brand<number, 'Seconds'>;
|
|
10
10
|
type AnalyticsRange = '24h' | '7d' | '30d' | '90d' | 'all';
|
|
11
|
+
type TrafficAnalyticsRange = AnalyticsRange | '180d';
|
|
11
12
|
interface RevenueSummary {
|
|
12
13
|
totalRevenue: USD;
|
|
13
14
|
purchaseCount: Count;
|
|
@@ -39,6 +40,8 @@ interface RecentPurchase {
|
|
|
39
40
|
couponId: string | null;
|
|
40
41
|
userName: string | null;
|
|
41
42
|
userEmail: string | null;
|
|
43
|
+
isTeam?: boolean;
|
|
44
|
+
seats?: number | null;
|
|
42
45
|
}
|
|
43
46
|
interface AttributionCount {
|
|
44
47
|
type: string;
|
|
@@ -49,6 +52,8 @@ interface ShortlinkPerformance {
|
|
|
49
52
|
slug: string;
|
|
50
53
|
url: string;
|
|
51
54
|
clicks: Count;
|
|
55
|
+
signups?: Count;
|
|
56
|
+
purchases?: Count;
|
|
52
57
|
}
|
|
53
58
|
type CommerceRecordKind = 'paid_conversion' | 'access_grant' | 'synthetic' | 'unknown';
|
|
54
59
|
interface RevenueBySource {
|
|
@@ -90,6 +95,20 @@ interface AttributionSignalSummary {
|
|
|
90
95
|
recoveredFromShortlinkAttributionTable?: Count;
|
|
91
96
|
internalFreeUpgrade: Count;
|
|
92
97
|
}
|
|
98
|
+
type AttributionQualityLaneName = 'strong' | 'medium' | 'weak' | 'unknown';
|
|
99
|
+
interface AttributionQualityLane {
|
|
100
|
+
lane: AttributionQualityLaneName;
|
|
101
|
+
purchases: Count;
|
|
102
|
+
revenue: USD;
|
|
103
|
+
revenuePercent: Percentage;
|
|
104
|
+
notes: string[];
|
|
105
|
+
}
|
|
106
|
+
interface AttributionQualitySummary {
|
|
107
|
+
strong: AttributionQualityLane;
|
|
108
|
+
medium: AttributionQualityLane;
|
|
109
|
+
weak: AttributionQualityLane;
|
|
110
|
+
unknown: AttributionQualityLane;
|
|
111
|
+
}
|
|
93
112
|
interface AttributionCoverage {
|
|
94
113
|
totalRevenue: USD;
|
|
95
114
|
attributedRevenue: USD;
|
|
@@ -107,6 +126,22 @@ interface AttributionCoverage {
|
|
|
107
126
|
syntheticPurchases?: Count;
|
|
108
127
|
commerce?: CommerceLaneSummary;
|
|
109
128
|
signals?: AttributionSignalSummary;
|
|
129
|
+
quality?: AttributionQualitySummary;
|
|
130
|
+
}
|
|
131
|
+
interface TrafficBreakdownRow {
|
|
132
|
+
sessions: Count;
|
|
133
|
+
users: Count;
|
|
134
|
+
sessionPercent: Percentage;
|
|
135
|
+
trafficSessionPercent: Percentage;
|
|
136
|
+
}
|
|
137
|
+
interface DeviceCategoryBreakdown extends TrafficBreakdownRow {
|
|
138
|
+
deviceCategory: string;
|
|
139
|
+
}
|
|
140
|
+
interface OperatingSystemBreakdown extends TrafficBreakdownRow {
|
|
141
|
+
operatingSystem: string;
|
|
142
|
+
}
|
|
143
|
+
interface ScreenResolutionBreakdown extends TrafficBreakdownRow {
|
|
144
|
+
screenResolution: string;
|
|
110
145
|
}
|
|
111
146
|
interface TrafficOverview {
|
|
112
147
|
sessions: Count;
|
|
@@ -115,6 +150,9 @@ interface TrafficOverview {
|
|
|
115
150
|
pageviews: Count;
|
|
116
151
|
avgSessionDuration: Seconds;
|
|
117
152
|
bounceRate: Percentage;
|
|
153
|
+
deviceCategories?: DeviceCategoryBreakdown[];
|
|
154
|
+
operatingSystems?: OperatingSystemBreakdown[];
|
|
155
|
+
screenResolutions?: ScreenResolutionBreakdown[];
|
|
118
156
|
}
|
|
119
157
|
interface TrafficDaily {
|
|
120
158
|
date: string;
|
|
@@ -448,4 +486,4 @@ type QueryResult<S extends SurfaceName> = {
|
|
|
448
486
|
fix: string;
|
|
449
487
|
};
|
|
450
488
|
|
|
451
|
-
export type { AnalyticsRange, AttributionCount, AttributionCoverage, AttributionSignalSummary, CheckoutAttributionReceipt, CheckoutSurveyFallbackAnswer, CheckoutSurveyFallbackReport, CommerceLaneSummary, CommerceRecordKind, ContentCorrelation, ConversionFunnel, Count, EmailCampaignFunnel, EmailCampaignKitLink, EmailCampaignShortlink, EmailRevenueOverview, Minutes, Percentage, ProductSurveyRevenueAnswer, ProductSurveyRevenueCorrelation, QueryOptions, QueryResult, RecentPurchase, RevenueByCountry, RevenueByProduct, RevenueBySource, RevenueDaily, RevenueSummary, Seconds, ShortlinkPerformance, SurfaceMap, SurfaceName, SurveyConversionByQuestion, SurveyListItem, SurveyQuestionBreakdown, SurveyResponseRow, SurveyResponsesDaily, SurveyRevenueCorrelation, SurveySummary, TopPage, TrafficDaily, TrafficOverview, TrafficRevenueCorrelation, TrafficSource, USD, ValuePathAnswerBucket, ValuePathStepBucket, ValuePathSummary, ValuePathTerminalBucket, YouTubeChannelOverview, YouTubeDaily, YouTubeRevenueCorrelation, YouTubeTrafficSource, YouTubeVideoPerformance };
|
|
489
|
+
export type { AnalyticsRange, AttributionCount, AttributionCoverage, AttributionQualityLane, AttributionQualityLaneName, AttributionQualitySummary, AttributionSignalSummary, CheckoutAttributionReceipt, CheckoutSurveyFallbackAnswer, CheckoutSurveyFallbackReport, CommerceLaneSummary, CommerceRecordKind, ContentCorrelation, ConversionFunnel, Count, DeviceCategoryBreakdown, EmailCampaignFunnel, EmailCampaignKitLink, EmailCampaignShortlink, EmailRevenueOverview, Minutes, OperatingSystemBreakdown, Percentage, ProductSurveyRevenueAnswer, ProductSurveyRevenueCorrelation, QueryOptions, QueryResult, RecentPurchase, RevenueByCountry, RevenueByProduct, RevenueBySource, RevenueDaily, RevenueSummary, ScreenResolutionBreakdown, Seconds, ShortlinkPerformance, SurfaceMap, SurfaceName, SurveyConversionByQuestion, SurveyListItem, SurveyQuestionBreakdown, SurveyResponseRow, SurveyResponsesDaily, SurveyRevenueCorrelation, SurveySummary, TopPage, TrafficAnalyticsRange, TrafficBreakdownRow, TrafficDaily, TrafficOverview, TrafficRevenueCorrelation, TrafficSource, USD, ValuePathAnswerBucket, ValuePathStepBucket, ValuePathSummary, ValuePathTerminalBucket, YouTubeChannelOverview, YouTubeDaily, YouTubeRevenueCorrelation, YouTubeTrafficSource, YouTubeVideoPerformance };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coursebuilder/analytics",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2-canary.0.0c9beb2ae",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"src"
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@google-analytics/data": "^4.0.0",
|
|
73
|
-
"zod": "^
|
|
73
|
+
"zod": "^4.1.12"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@types/react": "19.2.7",
|
|
@@ -83,10 +83,10 @@
|
|
|
83
83
|
"tsup": "8.0.2",
|
|
84
84
|
"typescript": "5.4.5",
|
|
85
85
|
"vitest": "1.6.0",
|
|
86
|
-
"@coursebuilder/ui": "2.0.
|
|
86
|
+
"@coursebuilder/ui": "2.4.1-canary.0.0c9beb2ae"
|
|
87
87
|
},
|
|
88
88
|
"peerDependencies": {
|
|
89
|
-
"@coursebuilder/ui": "
|
|
89
|
+
"@coursebuilder/ui": "2.4.1-canary.0",
|
|
90
90
|
"drizzle-orm": "*",
|
|
91
91
|
"lucide-react": "*",
|
|
92
92
|
"next": "*",
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
CardTitle,
|
|
30
30
|
} from '@coursebuilder/ui'
|
|
31
31
|
|
|
32
|
-
import type { AnalyticsRange } from '../types'
|
|
32
|
+
import type { AnalyticsRange, TrafficBreakdownRow } from '../types'
|
|
33
33
|
import { CountryChart } from './country-chart'
|
|
34
34
|
import { RevenueChart } from './revenue-chart'
|
|
35
35
|
|
|
@@ -107,6 +107,11 @@ export interface DashboardData {
|
|
|
107
107
|
totalUsers: number
|
|
108
108
|
newUsers: number
|
|
109
109
|
pageviews: number
|
|
110
|
+
deviceCategories?: Array<TrafficBreakdownRow & { deviceCategory: string }>
|
|
111
|
+
operatingSystems?: Array<TrafficBreakdownRow & { operatingSystem: string }>
|
|
112
|
+
screenResolutions?: Array<
|
|
113
|
+
TrafficBreakdownRow & { screenResolution: string }
|
|
114
|
+
>
|
|
110
115
|
} | null
|
|
111
116
|
attributionCoverage: {
|
|
112
117
|
totalRevenue: number
|
|
@@ -114,6 +119,16 @@ export interface DashboardData {
|
|
|
114
119
|
unattributedRevenue: number
|
|
115
120
|
attributionRate: number
|
|
116
121
|
totalPurchases: number
|
|
122
|
+
quality?: Record<
|
|
123
|
+
'strong' | 'medium' | 'weak' | 'unknown',
|
|
124
|
+
{
|
|
125
|
+
lane: 'strong' | 'medium' | 'weak' | 'unknown'
|
|
126
|
+
purchases: number
|
|
127
|
+
revenue: number
|
|
128
|
+
revenuePercent: number
|
|
129
|
+
notes: string[]
|
|
130
|
+
}
|
|
131
|
+
>
|
|
117
132
|
} | null
|
|
118
133
|
mux: VideoDashboardData | null
|
|
119
134
|
muxThumbnails: Record<string, string>
|
|
@@ -618,6 +633,133 @@ function ShortlinksCard({
|
|
|
618
633
|
)
|
|
619
634
|
}
|
|
620
635
|
|
|
636
|
+
// ─── Traffic Breakdowns ──────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
function fmtPct(v: number): string {
|
|
639
|
+
return `${v.toFixed(1)}%`
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function TrafficBreakdownTable<T extends TrafficBreakdownRow>({
|
|
643
|
+
title,
|
|
644
|
+
label,
|
|
645
|
+
rows,
|
|
646
|
+
getName,
|
|
647
|
+
}: {
|
|
648
|
+
title: string
|
|
649
|
+
label: string
|
|
650
|
+
rows: T[]
|
|
651
|
+
getName: (row: T) => string
|
|
652
|
+
}) {
|
|
653
|
+
if (!rows.length) return null
|
|
654
|
+
|
|
655
|
+
return (
|
|
656
|
+
<section className="min-w-0">
|
|
657
|
+
<div className="mb-2 flex items-baseline justify-between gap-2">
|
|
658
|
+
<h3 className="text-sm font-semibold">{title}</h3>
|
|
659
|
+
<span className="text-muted-foreground text-[11px]">
|
|
660
|
+
Top {rows.length}
|
|
661
|
+
</span>
|
|
662
|
+
</div>
|
|
663
|
+
<div className="overflow-x-auto rounded-xl border border-border/50">
|
|
664
|
+
<table className="w-full min-w-[440px] text-left text-sm">
|
|
665
|
+
<thead className="bg-muted/30 text-muted-foreground text-[11px] uppercase tracking-wide">
|
|
666
|
+
<tr>
|
|
667
|
+
<th className="px-3 py-2 font-medium">{label}</th>
|
|
668
|
+
<th className="px-3 py-2 text-right font-medium">Sessions</th>
|
|
669
|
+
<th className="px-3 py-2 text-right font-medium">Users</th>
|
|
670
|
+
<th className="px-3 py-2 text-right font-medium">In rows</th>
|
|
671
|
+
<th className="px-3 py-2 text-right font-medium">Of traffic</th>
|
|
672
|
+
</tr>
|
|
673
|
+
</thead>
|
|
674
|
+
<tbody>
|
|
675
|
+
{rows.map((row) => (
|
|
676
|
+
<tr
|
|
677
|
+
key={getName(row)}
|
|
678
|
+
className="border-t border-border/40 tabular-nums"
|
|
679
|
+
>
|
|
680
|
+
<td className="max-w-[220px] truncate px-3 py-2 font-medium">
|
|
681
|
+
{getName(row)}
|
|
682
|
+
</td>
|
|
683
|
+
<td className="px-3 py-2 text-right">
|
|
684
|
+
{row.sessions.toLocaleString()}
|
|
685
|
+
</td>
|
|
686
|
+
<td className="px-3 py-2 text-right">
|
|
687
|
+
{row.users.toLocaleString()}
|
|
688
|
+
</td>
|
|
689
|
+
<td className="px-3 py-2 text-right">
|
|
690
|
+
{fmtPct(row.sessionPercent)}
|
|
691
|
+
</td>
|
|
692
|
+
<td className="px-3 py-2 text-right">
|
|
693
|
+
{fmtPct(row.trafficSessionPercent)}
|
|
694
|
+
</td>
|
|
695
|
+
</tr>
|
|
696
|
+
))}
|
|
697
|
+
</tbody>
|
|
698
|
+
</table>
|
|
699
|
+
</div>
|
|
700
|
+
</section>
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function TrafficBreakdownsCard({
|
|
705
|
+
traffic,
|
|
706
|
+
}: {
|
|
707
|
+
traffic: NonNullable<DashboardData['traffic']>
|
|
708
|
+
}) {
|
|
709
|
+
const deviceCategories = traffic.deviceCategories ?? []
|
|
710
|
+
const operatingSystems = traffic.operatingSystems ?? []
|
|
711
|
+
const screenResolutions = traffic.screenResolutions ?? []
|
|
712
|
+
|
|
713
|
+
if (
|
|
714
|
+
deviceCategories.length === 0 &&
|
|
715
|
+
operatingSystems.length === 0 &&
|
|
716
|
+
screenResolutions.length === 0
|
|
717
|
+
) {
|
|
718
|
+
return null
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return (
|
|
722
|
+
<Card>
|
|
723
|
+
<CardHeader>
|
|
724
|
+
<div className="flex items-center gap-2.5">
|
|
725
|
+
<GlobeIcon className="text-muted-foreground h-4 w-4" />
|
|
726
|
+
<div>
|
|
727
|
+
<CardTitle className="text-sm font-semibold">
|
|
728
|
+
Traffic Details
|
|
729
|
+
</CardTitle>
|
|
730
|
+
<CardDescription className="text-[11px]">
|
|
731
|
+
Device, OS, and screen share. In rows is share inside the table.
|
|
732
|
+
Of traffic is share of total sessions.
|
|
733
|
+
</CardDescription>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
</CardHeader>
|
|
737
|
+
<CardContent>
|
|
738
|
+
<div className="grid gap-4 xl:grid-cols-3">
|
|
739
|
+
<TrafficBreakdownTable
|
|
740
|
+
title="Device category"
|
|
741
|
+
label="Device"
|
|
742
|
+
rows={deviceCategories}
|
|
743
|
+
getName={(row) => row.deviceCategory}
|
|
744
|
+
/>
|
|
745
|
+
<TrafficBreakdownTable
|
|
746
|
+
title="Operating systems"
|
|
747
|
+
label="OS"
|
|
748
|
+
rows={operatingSystems.slice(0, 10)}
|
|
749
|
+
getName={(row) => row.operatingSystem}
|
|
750
|
+
/>
|
|
751
|
+
<TrafficBreakdownTable
|
|
752
|
+
title="Screen resolutions"
|
|
753
|
+
label="Resolution"
|
|
754
|
+
rows={screenResolutions.slice(0, 10)}
|
|
755
|
+
getName={(row) => row.screenResolution}
|
|
756
|
+
/>
|
|
757
|
+
</div>
|
|
758
|
+
</CardContent>
|
|
759
|
+
</Card>
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
621
763
|
// ─── Range Selector ───────────────────────────────────────────────────────────
|
|
622
764
|
|
|
623
765
|
const RANGES: { value: AnalyticsRange; label: string }[] = [
|
|
@@ -697,7 +839,7 @@ export function OmnibusDashboard({
|
|
|
697
839
|
Analytics
|
|
698
840
|
</h1>
|
|
699
841
|
<p className="text-muted-foreground truncate text-[13px]">
|
|
700
|
-
Revenue · Attribution · Video · Traffic · Surveys
|
|
842
|
+
Revenue · Attribution · Video · Traffic · Surveys, {rangeLabel}
|
|
701
843
|
</p>
|
|
702
844
|
</div>
|
|
703
845
|
<div className="flex shrink-0 items-center gap-2">
|
|
@@ -796,6 +938,9 @@ export function OmnibusDashboard({
|
|
|
796
938
|
)}
|
|
797
939
|
</div>
|
|
798
940
|
|
|
941
|
+
{/* ── Traffic Details ──────────────────────────────────────── */}
|
|
942
|
+
{hasTraffic && <TrafficBreakdownsCard traffic={data.traffic!} />}
|
|
943
|
+
|
|
799
944
|
{/* ── Video Performance ────────────────────────────────────── */}
|
|
800
945
|
{hasMux && data.mux!.topVideos.length > 0 && (
|
|
801
946
|
<TopVideosCard
|
|
@@ -854,7 +999,7 @@ export function OmnibusDashboard({
|
|
|
854
999
|
<span className="text-foreground mt-1 block text-xl font-bold tabular-nums">
|
|
855
1000
|
{signupCount > 0
|
|
856
1001
|
? `${((purchaseAttrCount / signupCount) * 100).toFixed(1)}%`
|
|
857
|
-
: '
|
|
1002
|
+
: '-'}
|
|
858
1003
|
</span>
|
|
859
1004
|
</div>
|
|
860
1005
|
</div>
|
|
@@ -912,6 +1057,32 @@ export function OmnibusDashboard({
|
|
|
912
1057
|
</span>
|
|
913
1058
|
</div>
|
|
914
1059
|
</div>
|
|
1060
|
+
{data.attributionCoverage!.quality && (
|
|
1061
|
+
<div className="mt-4 grid gap-2 sm:grid-cols-4">
|
|
1062
|
+
{(['strong', 'medium', 'weak', 'unknown'] as const).map(
|
|
1063
|
+
(lane) => {
|
|
1064
|
+
const item = data.attributionCoverage!.quality![lane]
|
|
1065
|
+
return (
|
|
1066
|
+
<div
|
|
1067
|
+
key={lane}
|
|
1068
|
+
className="rounded-lg border border-border/40 p-3"
|
|
1069
|
+
>
|
|
1070
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
1071
|
+
{lane}
|
|
1072
|
+
</span>
|
|
1073
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
1074
|
+
{fmt$(item.revenue)}
|
|
1075
|
+
</span>
|
|
1076
|
+
<span className="text-muted-foreground text-[11px]">
|
|
1077
|
+
{item.purchases.toLocaleString()} purchases ·{' '}
|
|
1078
|
+
{(item.revenuePercent * 100).toFixed(1)}%
|
|
1079
|
+
</span>
|
|
1080
|
+
</div>
|
|
1081
|
+
)
|
|
1082
|
+
},
|
|
1083
|
+
)}
|
|
1084
|
+
</div>
|
|
1085
|
+
)}
|
|
915
1086
|
</CardContent>
|
|
916
1087
|
</Card>
|
|
917
1088
|
)}
|
|
@@ -1593,18 +1764,18 @@ export function OmnibusDashboard({
|
|
|
1593
1764
|
{p.totalAmount > 0 ? fmt$(p.totalAmount) : 'Free'}
|
|
1594
1765
|
</td>
|
|
1595
1766
|
<td className="py-2.5 pr-4 text-right text-sm tabular-nums">
|
|
1596
|
-
{p.seats ?? '
|
|
1767
|
+
{p.seats ?? '-'}
|
|
1597
1768
|
</td>
|
|
1598
1769
|
<td className="max-w-[200px] truncate py-2.5 pr-4 text-sm font-medium">
|
|
1599
1770
|
{p.productName}
|
|
1600
1771
|
</td>
|
|
1601
1772
|
<td className="text-muted-foreground hidden max-w-[150px] truncate py-2.5 pr-4 text-sm sm:table-cell">
|
|
1602
|
-
{p.userName ?? p.userEmail ?? '
|
|
1773
|
+
{p.userName ?? p.userEmail ?? '-'}
|
|
1603
1774
|
</td>
|
|
1604
1775
|
<td className="text-muted-foreground hidden py-2.5 pr-4 text-sm sm:table-cell">
|
|
1605
1776
|
{p.country
|
|
1606
1777
|
? `${countryFlag(p.country)} ${p.country}`
|
|
1607
|
-
: '
|
|
1778
|
+
: '-'}
|
|
1608
1779
|
</td>
|
|
1609
1780
|
<td className="text-muted-foreground whitespace-nowrap py-2.5 pr-4 text-sm">
|
|
1610
1781
|
{fmtAgo(p.createdAt)}
|
package/src/engine.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
QueryResult,
|
|
6
6
|
SurfaceMap,
|
|
7
7
|
SurfaceName,
|
|
8
|
+
TrafficAnalyticsRange,
|
|
8
9
|
} from './types'
|
|
9
10
|
|
|
10
11
|
export type AnalyticsProvider = Record<
|
|
@@ -14,7 +15,7 @@ export type AnalyticsProvider = Record<
|
|
|
14
15
|
|
|
15
16
|
export type ProviderMap = Record<string, AnalyticsProvider>
|
|
16
17
|
|
|
17
|
-
function toGA4Range(range: AnalyticsRange):
|
|
18
|
+
function toGA4Range(range: AnalyticsRange): TrafficAnalyticsRange {
|
|
18
19
|
if (range === 'all') return '90d'
|
|
19
20
|
return range
|
|
20
21
|
}
|
|
@@ -71,7 +72,7 @@ async function invokeSurface<S extends SurfaceName>(
|
|
|
71
72
|
case 'traffic/pages':
|
|
72
73
|
case 'traffic/sources': {
|
|
73
74
|
const fn = provider[entry.fn] as (
|
|
74
|
-
range:
|
|
75
|
+
range: TrafficAnalyticsRange,
|
|
75
76
|
limit?: number,
|
|
76
77
|
) => Promise<SurfaceMap[S]>
|
|
77
78
|
if (surface === 'traffic/pages' || surface === 'traffic/sources') {
|
|
@@ -143,6 +143,35 @@ export function createDatabaseProvider(
|
|
|
143
143
|
OR ${exactShortlinkPurchaseAttributionSignalSql}
|
|
144
144
|
)`
|
|
145
145
|
|
|
146
|
+
const strongAttributionQualitySql = sql`(
|
|
147
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.clickIds') IS NOT NULL
|
|
148
|
+
OR JSON_EXTRACT(${purchases.fields}, '$.attribution.utm') IS NOT NULL
|
|
149
|
+
OR JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink') IS NOT NULL
|
|
150
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
|
|
151
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.shortlinkRef')) IS NOT NULL
|
|
152
|
+
)`
|
|
153
|
+
|
|
154
|
+
const mediumAttributionQualitySql = sql`(
|
|
155
|
+
NOT ${strongAttributionQualitySql}
|
|
156
|
+
AND ${exactShortlinkPurchaseAttributionSignalSql}
|
|
157
|
+
)`
|
|
158
|
+
|
|
159
|
+
const weakAttributionQualitySql = sql`(
|
|
160
|
+
NOT ${strongAttributionQualitySql}
|
|
161
|
+
AND NOT ${exactShortlinkPurchaseAttributionSignalSql}
|
|
162
|
+
AND (
|
|
163
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.ga.clientId') IS NOT NULL
|
|
164
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
|
|
165
|
+
OR JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource') IS NOT NULL
|
|
166
|
+
)
|
|
167
|
+
)`
|
|
168
|
+
|
|
169
|
+
const unknownAttributionQualitySql = sql`(
|
|
170
|
+
NOT ${strongAttributionQualitySql}
|
|
171
|
+
AND NOT ${mediumAttributionQualitySql}
|
|
172
|
+
AND NOT ${weakAttributionQualitySql}
|
|
173
|
+
)`
|
|
174
|
+
|
|
146
175
|
function rangeToDate(range: AnalyticsTimeRange): Date | null {
|
|
147
176
|
if (range === 'all') return null
|
|
148
177
|
const now = new Date()
|
|
@@ -690,6 +719,24 @@ export function createDatabaseProvider(
|
|
|
690
719
|
.where(and(...signalConditions, condition))
|
|
691
720
|
return row?.count ?? 0
|
|
692
721
|
}
|
|
722
|
+
const qualityLane = async (
|
|
723
|
+
lane: 'strong' | 'medium' | 'weak' | 'unknown',
|
|
724
|
+
condition: any,
|
|
725
|
+
notes: string[],
|
|
726
|
+
) => {
|
|
727
|
+
const [row] = await db
|
|
728
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
729
|
+
.from(purchases)
|
|
730
|
+
.where(and(...conditions, condition))
|
|
731
|
+
const revenue = Number(row?.total ?? 0)
|
|
732
|
+
return {
|
|
733
|
+
lane,
|
|
734
|
+
purchases: row?.count ?? 0,
|
|
735
|
+
revenue,
|
|
736
|
+
revenuePercent: totalRevenue > 0 ? revenue / totalRevenue : 0,
|
|
737
|
+
notes,
|
|
738
|
+
}
|
|
739
|
+
}
|
|
693
740
|
const [
|
|
694
741
|
shortlinkCount,
|
|
695
742
|
utmCount,
|
|
@@ -736,6 +783,21 @@ export function createDatabaseProvider(
|
|
|
736
783
|
)
|
|
737
784
|
const unattributedRevenue = totalRevenue - attributedRevenue
|
|
738
785
|
const paidPurchases = totals?.count ?? 0
|
|
786
|
+
const [strongQuality, mediumQuality, weakQuality, unknownQuality] =
|
|
787
|
+
await Promise.all([
|
|
788
|
+
qualityLane('strong', strongAttributionQualitySql, [
|
|
789
|
+
'Purchase-field UTM, shortlink, or paid click evidence.',
|
|
790
|
+
]),
|
|
791
|
+
qualityLane('medium', mediumAttributionQualitySql, [
|
|
792
|
+
'Recovered from exact shortlink attribution table purchase match.',
|
|
793
|
+
]),
|
|
794
|
+
qualityLane('weak', weakAttributionQualitySql, [
|
|
795
|
+
'GA client ID or self-reported source without stronger campaign evidence.',
|
|
796
|
+
]),
|
|
797
|
+
qualityLane('unknown', unknownAttributionQualitySql, [
|
|
798
|
+
'No usable attribution evidence for paid revenue.',
|
|
799
|
+
]),
|
|
800
|
+
])
|
|
739
801
|
|
|
740
802
|
return {
|
|
741
803
|
totalRevenue,
|
|
@@ -764,6 +826,12 @@ export function createDatabaseProvider(
|
|
|
764
826
|
recoveredFromShortlinkAttributionTableCount,
|
|
765
827
|
internalFreeUpgrade: commerce.freeUpgrades,
|
|
766
828
|
},
|
|
829
|
+
quality: {
|
|
830
|
+
strong: strongQuality,
|
|
831
|
+
medium: mediumQuality,
|
|
832
|
+
weak: weakQuality,
|
|
833
|
+
unknown: unknownQuality,
|
|
834
|
+
},
|
|
767
835
|
}
|
|
768
836
|
}
|
|
769
837
|
|
package/src/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type Minutes = Brand<number, 'Minutes'>
|
|
|
9
9
|
export type Seconds = Brand<number, 'Seconds'>
|
|
10
10
|
|
|
11
11
|
export type AnalyticsRange = '24h' | '7d' | '30d' | '90d' | 'all'
|
|
12
|
+
export type TrafficAnalyticsRange = AnalyticsRange | '180d'
|
|
12
13
|
|
|
13
14
|
export interface RevenueSummary {
|
|
14
15
|
totalRevenue: USD
|
|
@@ -45,6 +46,8 @@ export interface RecentPurchase {
|
|
|
45
46
|
couponId: string | null
|
|
46
47
|
userName: string | null
|
|
47
48
|
userEmail: string | null
|
|
49
|
+
isTeam?: boolean
|
|
50
|
+
seats?: number | null
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
export interface AttributionCount {
|
|
@@ -57,6 +60,8 @@ export interface ShortlinkPerformance {
|
|
|
57
60
|
slug: string
|
|
58
61
|
url: string
|
|
59
62
|
clicks: Count
|
|
63
|
+
signups?: Count
|
|
64
|
+
purchases?: Count
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
export type CommerceRecordKind =
|
|
@@ -109,6 +114,27 @@ export interface AttributionSignalSummary {
|
|
|
109
114
|
internalFreeUpgrade: Count
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
export type AttributionQualityLaneName =
|
|
118
|
+
| 'strong'
|
|
119
|
+
| 'medium'
|
|
120
|
+
| 'weak'
|
|
121
|
+
| 'unknown'
|
|
122
|
+
|
|
123
|
+
export interface AttributionQualityLane {
|
|
124
|
+
lane: AttributionQualityLaneName
|
|
125
|
+
purchases: Count
|
|
126
|
+
revenue: USD
|
|
127
|
+
revenuePercent: Percentage
|
|
128
|
+
notes: string[]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface AttributionQualitySummary {
|
|
132
|
+
strong: AttributionQualityLane
|
|
133
|
+
medium: AttributionQualityLane
|
|
134
|
+
weak: AttributionQualityLane
|
|
135
|
+
unknown: AttributionQualityLane
|
|
136
|
+
}
|
|
137
|
+
|
|
112
138
|
export interface AttributionCoverage {
|
|
113
139
|
totalRevenue: USD
|
|
114
140
|
attributedRevenue: USD
|
|
@@ -126,6 +152,26 @@ export interface AttributionCoverage {
|
|
|
126
152
|
syntheticPurchases?: Count
|
|
127
153
|
commerce?: CommerceLaneSummary
|
|
128
154
|
signals?: AttributionSignalSummary
|
|
155
|
+
quality?: AttributionQualitySummary
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface TrafficBreakdownRow {
|
|
159
|
+
sessions: Count
|
|
160
|
+
users: Count
|
|
161
|
+
sessionPercent: Percentage
|
|
162
|
+
trafficSessionPercent: Percentage
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface DeviceCategoryBreakdown extends TrafficBreakdownRow {
|
|
166
|
+
deviceCategory: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface OperatingSystemBreakdown extends TrafficBreakdownRow {
|
|
170
|
+
operatingSystem: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ScreenResolutionBreakdown extends TrafficBreakdownRow {
|
|
174
|
+
screenResolution: string
|
|
129
175
|
}
|
|
130
176
|
|
|
131
177
|
export interface TrafficOverview {
|
|
@@ -135,6 +181,9 @@ export interface TrafficOverview {
|
|
|
135
181
|
pageviews: Count
|
|
136
182
|
avgSessionDuration: Seconds
|
|
137
183
|
bounceRate: Percentage
|
|
184
|
+
deviceCategories?: DeviceCategoryBreakdown[]
|
|
185
|
+
operatingSystems?: OperatingSystemBreakdown[]
|
|
186
|
+
screenResolutions?: ScreenResolutionBreakdown[]
|
|
138
187
|
}
|
|
139
188
|
|
|
140
189
|
export interface TrafficDaily {
|