@coursebuilder/analytics 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/api/index.d.ts +158 -0
- package/dist/api/index.js +317 -0
- package/dist/api/index.js.map +1 -0
- package/dist/catalog.d.ts +14 -0
- package/dist/catalog.js +209 -0
- package/dist/catalog.js.map +1 -0
- package/dist/components/index.d.ts +172 -0
- package/dist/components/index.js +1258 -0
- package/dist/components/index.js.map +1 -0
- package/dist/engine.d.ts +20 -0
- package/dist/engine.js +350 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +353 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/database.d.ts +79 -0
- package/dist/providers/database.js +533 -0
- package/dist/providers/database.js.map +1 -0
- package/dist/providers/derived.d.ts +45 -0
- package/dist/providers/derived.js +32 -0
- package/dist/providers/derived.js.map +1 -0
- package/dist/providers/ga4.d.ts +43 -0
- package/dist/providers/ga4.js +220 -0
- package/dist/providers/ga4.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.js +1239 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/mux.d.ts +103 -0
- package/dist/providers/mux.js +241 -0
- package/dist/providers/mux.js.map +1 -0
- package/dist/providers/survey.d.ts +102 -0
- package/dist/providers/survey.js +233 -0
- package/dist/providers/survey.js.map +1 -0
- package/dist/types.d.ts +303 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +101 -0
- package/src/api/catalog-handler.ts +321 -0
- package/src/api/index.ts +4 -0
- package/src/api/token-handler.ts +71 -0
- package/src/catalog.ts +223 -0
- package/src/components/country-chart.tsx +114 -0
- package/src/components/index.ts +5 -0
- package/src/components/omnibus-dashboard.tsx +1460 -0
- package/src/components/revenue-chart.tsx +251 -0
- package/src/components/use-chart-colors.ts +75 -0
- package/src/engine.ts +201 -0
- package/src/index.ts +7 -0
- package/src/providers/database.ts +795 -0
- package/src/providers/derived.ts +79 -0
- package/src/providers/ga4.ts +173 -0
- package/src/providers/index.ts +44 -0
- package/src/providers/mux.ts +438 -0
- package/src/providers/survey.ts +487 -0
- package/src/types.ts +333 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useMemo } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Area,
|
|
6
|
+
AreaChart,
|
|
7
|
+
CartesianGrid,
|
|
8
|
+
ResponsiveContainer,
|
|
9
|
+
Tooltip,
|
|
10
|
+
XAxis,
|
|
11
|
+
YAxis,
|
|
12
|
+
} from 'recharts'
|
|
13
|
+
|
|
14
|
+
import { useChartColors } from './use-chart-colors'
|
|
15
|
+
|
|
16
|
+
interface DayData {
|
|
17
|
+
date: string
|
|
18
|
+
revenue: number
|
|
19
|
+
count: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RevenueChartProps {
|
|
23
|
+
data: DayData[]
|
|
24
|
+
previousData?: DayData[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatDate(dateStr: string) {
|
|
28
|
+
const d = new Date(dateStr + 'T00:00:00')
|
|
29
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatCurrency(value: number): string {
|
|
33
|
+
if (value >= 1000) return `$${(value / 1000).toFixed(1)}k`
|
|
34
|
+
return `$${value.toFixed(0)}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function CustomTooltip({
|
|
38
|
+
active,
|
|
39
|
+
payload,
|
|
40
|
+
label,
|
|
41
|
+
}: {
|
|
42
|
+
active?: boolean
|
|
43
|
+
payload?: { dataKey: string; value: number; payload: MergedDay }[]
|
|
44
|
+
label?: string
|
|
45
|
+
}) {
|
|
46
|
+
if (!active || !payload?.length || !label) return null
|
|
47
|
+
|
|
48
|
+
const merged = payload[0]?.payload
|
|
49
|
+
if (!merged) return null
|
|
50
|
+
|
|
51
|
+
const current = merged.revenue ?? 0
|
|
52
|
+
const previous = merged.prevRevenue ?? 0
|
|
53
|
+
const hasPrev = previous > 0
|
|
54
|
+
|
|
55
|
+
let delta = ''
|
|
56
|
+
let deltaColor = ''
|
|
57
|
+
if (hasPrev && current > 0) {
|
|
58
|
+
const pct = ((current - previous) / previous) * 100
|
|
59
|
+
delta = pct >= 0 ? `+${pct.toFixed(0)}%` : `${pct.toFixed(0)}%`
|
|
60
|
+
deltaColor = pct >= 0 ? 'text-emerald-500' : 'text-red-400'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="border-border/50 bg-card rounded-lg border px-3 py-2 shadow-xl">
|
|
65
|
+
<p className="text-muted-foreground text-xs">{formatDate(label)}</p>
|
|
66
|
+
<div className="mt-1 flex items-baseline gap-2">
|
|
67
|
+
<p className="text-foreground text-lg font-bold tabular-nums">
|
|
68
|
+
$
|
|
69
|
+
{current.toLocaleString(undefined, {
|
|
70
|
+
minimumFractionDigits: 0,
|
|
71
|
+
maximumFractionDigits: 0,
|
|
72
|
+
})}
|
|
73
|
+
</p>
|
|
74
|
+
{delta && (
|
|
75
|
+
<span className={`text-xs font-semibold ${deltaColor}`}>{delta}</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
<p className="text-muted-foreground text-xs">
|
|
79
|
+
{merged.count ?? 0} purchases
|
|
80
|
+
</p>
|
|
81
|
+
{hasPrev && (
|
|
82
|
+
<p className="text-muted-foreground border-border/30 mt-1 border-t pt-1 text-[11px]">
|
|
83
|
+
prev: $
|
|
84
|
+
{previous.toLocaleString(undefined, {
|
|
85
|
+
minimumFractionDigits: 0,
|
|
86
|
+
maximumFractionDigits: 0,
|
|
87
|
+
})}{' '}
|
|
88
|
+
· {merged.prevCount ?? 0} purchases
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface MergedDay {
|
|
96
|
+
date: string
|
|
97
|
+
revenue: number
|
|
98
|
+
count: number
|
|
99
|
+
prevRevenue: number | null
|
|
100
|
+
prevCount: number | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function RevenueChart({ data, previousData = [] }: RevenueChartProps) {
|
|
104
|
+
const colors = useChartColors()
|
|
105
|
+
|
|
106
|
+
const merged = useMemo<MergedDay[]>(() => {
|
|
107
|
+
if (previousData.length === 0) {
|
|
108
|
+
return data.map((d) => ({
|
|
109
|
+
...d,
|
|
110
|
+
prevRevenue: null,
|
|
111
|
+
prevCount: null,
|
|
112
|
+
}))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Align previous period by day offset (day 0 = start of each period)
|
|
116
|
+
return data.map((d, i) => {
|
|
117
|
+
const prev = previousData[i]
|
|
118
|
+
return {
|
|
119
|
+
...d,
|
|
120
|
+
prevRevenue: prev?.revenue ?? null,
|
|
121
|
+
prevCount: prev?.count ?? null,
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}, [data, previousData])
|
|
125
|
+
|
|
126
|
+
const maxRevenue = useMemo(
|
|
127
|
+
() =>
|
|
128
|
+
Math.max(
|
|
129
|
+
...merged.map((d) => d.revenue),
|
|
130
|
+
...merged.map((d) => d.prevRevenue ?? 0),
|
|
131
|
+
0,
|
|
132
|
+
),
|
|
133
|
+
[merged],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Use log scale when the max is >10x the median — prevents spikes from crushing everything
|
|
137
|
+
const nonZeroVals = useMemo(
|
|
138
|
+
() =>
|
|
139
|
+
merged
|
|
140
|
+
.map((d) => d.revenue)
|
|
141
|
+
.filter((v) => v > 0)
|
|
142
|
+
.sort((a, b) => a - b),
|
|
143
|
+
[merged],
|
|
144
|
+
)
|
|
145
|
+
const useLog = useMemo(() => {
|
|
146
|
+
if (nonZeroVals.length < 3) return false
|
|
147
|
+
const median = nonZeroVals[Math.floor(nonZeroVals.length / 2)]!
|
|
148
|
+
return maxRevenue > median * 10
|
|
149
|
+
}, [nonZeroVals, maxRevenue])
|
|
150
|
+
const logFloor =
|
|
151
|
+
nonZeroVals.length > 0 ? Math.floor(nonZeroVals[0]! * 0.7) : 100
|
|
152
|
+
|
|
153
|
+
const hasPrev = previousData.length > 0
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className="h-[280px] w-full">
|
|
157
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
158
|
+
<AreaChart
|
|
159
|
+
data={merged}
|
|
160
|
+
margin={{ top: 8, right: 8, left: -12, bottom: 0 }}
|
|
161
|
+
>
|
|
162
|
+
<defs>
|
|
163
|
+
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
|
164
|
+
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.3} />
|
|
165
|
+
<stop offset="100%" stopColor="#22c55e" stopOpacity={0} />
|
|
166
|
+
</linearGradient>
|
|
167
|
+
</defs>
|
|
168
|
+
<CartesianGrid
|
|
169
|
+
strokeDasharray="3 3"
|
|
170
|
+
stroke={colors.gridLine}
|
|
171
|
+
vertical={false}
|
|
172
|
+
/>
|
|
173
|
+
<XAxis
|
|
174
|
+
dataKey="date"
|
|
175
|
+
tickFormatter={formatDate}
|
|
176
|
+
tick={{ fill: colors.mutedForeground, fontSize: 11 }}
|
|
177
|
+
axisLine={{ stroke: colors.gridLine }}
|
|
178
|
+
tickLine={false}
|
|
179
|
+
interval="preserveStartEnd"
|
|
180
|
+
minTickGap={40}
|
|
181
|
+
/>
|
|
182
|
+
<YAxis
|
|
183
|
+
tick={{ fill: colors.mutedForeground, fontSize: 11 }}
|
|
184
|
+
axisLine={false}
|
|
185
|
+
tickLine={false}
|
|
186
|
+
scale={useLog ? 'log' : 'auto'}
|
|
187
|
+
domain={
|
|
188
|
+
useLog ? [logFloor, 'auto'] : [0, Math.ceil(maxRevenue * 1.1)]
|
|
189
|
+
}
|
|
190
|
+
allowDataOverflow={useLog}
|
|
191
|
+
tickFormatter={formatCurrency}
|
|
192
|
+
/>
|
|
193
|
+
<Tooltip
|
|
194
|
+
content={<CustomTooltip />}
|
|
195
|
+
cursor={{
|
|
196
|
+
stroke: '#22c55e',
|
|
197
|
+
strokeWidth: 1,
|
|
198
|
+
strokeDasharray: '4 4',
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
{hasPrev && (
|
|
202
|
+
<Area
|
|
203
|
+
type="linear"
|
|
204
|
+
dataKey="prevRevenue"
|
|
205
|
+
stroke={colors.mutedForeground}
|
|
206
|
+
strokeWidth={1.5}
|
|
207
|
+
strokeDasharray="4 3"
|
|
208
|
+
fill="none"
|
|
209
|
+
dot={false}
|
|
210
|
+
activeDot={false}
|
|
211
|
+
connectNulls
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
<Area
|
|
215
|
+
type="linear"
|
|
216
|
+
dataKey="revenue"
|
|
217
|
+
stroke="#22c55e"
|
|
218
|
+
strokeWidth={2}
|
|
219
|
+
fill="url(#revenueGradient)"
|
|
220
|
+
dot={false}
|
|
221
|
+
activeDot={{
|
|
222
|
+
r: 5,
|
|
223
|
+
fill: '#22c55e',
|
|
224
|
+
stroke: colors.cardBg,
|
|
225
|
+
strokeWidth: 2,
|
|
226
|
+
}}
|
|
227
|
+
/>
|
|
228
|
+
</AreaChart>
|
|
229
|
+
</ResponsiveContainer>
|
|
230
|
+
{hasPrev && (
|
|
231
|
+
<div className="mt-2 flex items-center gap-4 text-[11px]">
|
|
232
|
+
<span className="flex items-center gap-1.5">
|
|
233
|
+
<span className="inline-block h-0.5 w-4 rounded bg-emerald-500" />
|
|
234
|
+
<span className="text-muted-foreground">Current period</span>
|
|
235
|
+
</span>
|
|
236
|
+
<span className="flex items-center gap-1.5">
|
|
237
|
+
<span
|
|
238
|
+
className="inline-block h-0.5 w-4 rounded"
|
|
239
|
+
style={{
|
|
240
|
+
background: colors.mutedForeground,
|
|
241
|
+
backgroundImage:
|
|
242
|
+
'repeating-linear-gradient(90deg, transparent, transparent 3px, currentColor 3px, currentColor 5px)',
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
<span className="text-muted-foreground">Previous period</span>
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface ChartColors {
|
|
6
|
+
primary: string
|
|
7
|
+
primaryMuted: string
|
|
8
|
+
foreground: string
|
|
9
|
+
mutedForeground: string
|
|
10
|
+
gridLine: string
|
|
11
|
+
cardBg: string
|
|
12
|
+
hoverBg: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FALLBACK: ChartColors = {
|
|
16
|
+
primary: '#d4a053',
|
|
17
|
+
primaryMuted: 'rgba(212, 160, 83, 0.2)',
|
|
18
|
+
foreground: '#e5e5e5',
|
|
19
|
+
mutedForeground: '#999',
|
|
20
|
+
gridLine: '#333',
|
|
21
|
+
cardBg: '#1a1a1a',
|
|
22
|
+
hoverBg: '#222',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getCSSVar(name: string): string {
|
|
26
|
+
return getComputedStyle(document.documentElement)
|
|
27
|
+
.getPropertyValue(name)
|
|
28
|
+
.trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function oklchToUsable(raw: string): string {
|
|
32
|
+
if (!raw) return ''
|
|
33
|
+
// raw is like "oklch(0.85 0.12 79.28)" — browsers handle it fine
|
|
34
|
+
return raw.startsWith('oklch') ? raw : raw
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useChartColors(): ChartColors {
|
|
38
|
+
const [colors, setColors] = useState<ChartColors>(FALLBACK)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
function update() {
|
|
42
|
+
const primary = getCSSVar('--primary')
|
|
43
|
+
const muted = getCSSVar('--muted')
|
|
44
|
+
const mutedFg = getCSSVar('--muted-foreground')
|
|
45
|
+
const fg = getCSSVar('--foreground')
|
|
46
|
+
const border = getCSSVar('--border')
|
|
47
|
+
const card = getCSSVar('--card')
|
|
48
|
+
|
|
49
|
+
if (!primary) return
|
|
50
|
+
|
|
51
|
+
setColors({
|
|
52
|
+
primary: oklchToUsable(primary),
|
|
53
|
+
primaryMuted: oklchToUsable(primary).replace(')', ' / 0.2)'),
|
|
54
|
+
foreground: oklchToUsable(fg),
|
|
55
|
+
mutedForeground: oklchToUsable(mutedFg),
|
|
56
|
+
gridLine: oklchToUsable(border),
|
|
57
|
+
cardBg: oklchToUsable(card),
|
|
58
|
+
hoverBg: oklchToUsable(muted),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
update()
|
|
63
|
+
|
|
64
|
+
// Re-read when theme changes (class mutation on <html>)
|
|
65
|
+
const observer = new MutationObserver(update)
|
|
66
|
+
observer.observe(document.documentElement, {
|
|
67
|
+
attributes: true,
|
|
68
|
+
attributeFilter: ['class'],
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return () => observer.disconnect()
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
return colors
|
|
75
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { catalog, type SurfaceEntry } from './catalog'
|
|
2
|
+
import type {
|
|
3
|
+
AnalyticsRange,
|
|
4
|
+
QueryOptions,
|
|
5
|
+
QueryResult,
|
|
6
|
+
SurfaceMap,
|
|
7
|
+
SurfaceName,
|
|
8
|
+
} from './types'
|
|
9
|
+
|
|
10
|
+
export type AnalyticsProvider = Record<
|
|
11
|
+
string,
|
|
12
|
+
((...args: any[]) => Promise<any>) | unknown
|
|
13
|
+
>
|
|
14
|
+
|
|
15
|
+
export type ProviderMap = Record<string, AnalyticsProvider>
|
|
16
|
+
|
|
17
|
+
function toGA4Range(range: AnalyticsRange): '24h' | '7d' | '30d' | '90d' {
|
|
18
|
+
if (range === 'all') return '90d'
|
|
19
|
+
return range
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toYouTubeRange(range: AnalyticsRange): '24h' | '7d' | '30d' | '90d' {
|
|
23
|
+
if (range === 'all') return '90d'
|
|
24
|
+
return range
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function invokeSurface<S extends SurfaceName>(
|
|
28
|
+
providers: ProviderMap,
|
|
29
|
+
surface: S,
|
|
30
|
+
range: AnalyticsRange,
|
|
31
|
+
limit: number,
|
|
32
|
+
): Promise<SurfaceMap[S] | null> {
|
|
33
|
+
const entry = catalog[surface]
|
|
34
|
+
const provider = providers[entry.provider]
|
|
35
|
+
|
|
36
|
+
if (!provider) {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check that the provider actually exposes the expected function.
|
|
41
|
+
// A provider may exist but not support every surface in the catalog
|
|
42
|
+
// (e.g. CWA's derived provider omits YouTube correlation).
|
|
43
|
+
if (typeof provider[entry.fn] !== 'function') {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
switch (surface) {
|
|
48
|
+
case 'purchases/recent': {
|
|
49
|
+
const fn = provider[entry.fn] as (limit: number) => Promise<SurfaceMap[S]>
|
|
50
|
+
return fn(limit)
|
|
51
|
+
}
|
|
52
|
+
case 'youtube': {
|
|
53
|
+
const fn = provider[entry.fn] as () => Promise<SurfaceMap[S] | null>
|
|
54
|
+
return fn()
|
|
55
|
+
}
|
|
56
|
+
case 'youtube/videos':
|
|
57
|
+
case 'youtube/daily':
|
|
58
|
+
case 'youtube/sources': {
|
|
59
|
+
const fn = provider[entry.fn] as (
|
|
60
|
+
range: '24h' | '7d' | '30d' | '90d',
|
|
61
|
+
limit?: number,
|
|
62
|
+
) => Promise<SurfaceMap[S] | null>
|
|
63
|
+
if (surface === 'youtube/videos') {
|
|
64
|
+
return fn(toYouTubeRange(range), limit)
|
|
65
|
+
}
|
|
66
|
+
return fn(toYouTubeRange(range))
|
|
67
|
+
}
|
|
68
|
+
case 'traffic':
|
|
69
|
+
case 'traffic/daily':
|
|
70
|
+
case 'traffic/pages':
|
|
71
|
+
case 'traffic/sources': {
|
|
72
|
+
const fn = provider[entry.fn] as (
|
|
73
|
+
range: '24h' | '7d' | '30d' | '90d',
|
|
74
|
+
limit?: number,
|
|
75
|
+
) => Promise<SurfaceMap[S]>
|
|
76
|
+
if (surface === 'traffic/pages' || surface === 'traffic/sources') {
|
|
77
|
+
return fn(toGA4Range(range), limit)
|
|
78
|
+
}
|
|
79
|
+
return fn(toGA4Range(range))
|
|
80
|
+
}
|
|
81
|
+
case 'surveys':
|
|
82
|
+
case 'surveys/list':
|
|
83
|
+
case 'surveys/daily': {
|
|
84
|
+
const fn = provider[entry.fn] as (
|
|
85
|
+
range: AnalyticsRange,
|
|
86
|
+
) => Promise<SurfaceMap[S]>
|
|
87
|
+
return fn(range)
|
|
88
|
+
}
|
|
89
|
+
case 'attribution/email-campaigns': {
|
|
90
|
+
const fn = provider[entry.fn] as (
|
|
91
|
+
range: AnalyticsRange,
|
|
92
|
+
limit?: number,
|
|
93
|
+
) => Promise<SurfaceMap[S]>
|
|
94
|
+
return fn(range, limit)
|
|
95
|
+
}
|
|
96
|
+
case 'surveys/questions':
|
|
97
|
+
case 'surveys/responses': {
|
|
98
|
+
const fn = provider[entry.fn] as (
|
|
99
|
+
range: AnalyticsRange,
|
|
100
|
+
limit: number,
|
|
101
|
+
) => Promise<SurfaceMap[S]>
|
|
102
|
+
return fn(range, limit)
|
|
103
|
+
}
|
|
104
|
+
default: {
|
|
105
|
+
const fn = provider[entry.fn] as (
|
|
106
|
+
range: AnalyticsRange,
|
|
107
|
+
limit?: number,
|
|
108
|
+
) => Promise<SurfaceMap[S] | null>
|
|
109
|
+
if (
|
|
110
|
+
surface === 'attribution/content' ||
|
|
111
|
+
surface === 'correlation/survey-revenue'
|
|
112
|
+
) {
|
|
113
|
+
return fn(range, limit)
|
|
114
|
+
}
|
|
115
|
+
return fn(range)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Creates an analytics engine bound to the given provider map.
|
|
122
|
+
*
|
|
123
|
+
* @param providers - A map of provider name → provider implementation. Keys
|
|
124
|
+
* must match the `provider` field values used in the catalog
|
|
125
|
+
* ('database', 'ga4', 'youtube', 'derived', 'newsletter', 'survey').
|
|
126
|
+
* @returns An object with `query`, `queryMany`, and `getCatalog` methods.
|
|
127
|
+
*/
|
|
128
|
+
export function createAnalyticsEngine(providers: ProviderMap) {
|
|
129
|
+
async function query<S extends SurfaceName>(
|
|
130
|
+
surface: S,
|
|
131
|
+
options?: QueryOptions,
|
|
132
|
+
): Promise<QueryResult<S>> {
|
|
133
|
+
const range = options?.range ?? '30d'
|
|
134
|
+
const limit = options?.limit ?? 20
|
|
135
|
+
const entry = catalog[surface]
|
|
136
|
+
const startMs = Date.now()
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const data = await invokeSurface(providers, surface, range, limit)
|
|
140
|
+
|
|
141
|
+
if (data === null) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
surface,
|
|
145
|
+
error: {
|
|
146
|
+
message: `${entry.provider} is not available`,
|
|
147
|
+
code: `${entry.provider.toUpperCase()}_UNAVAILABLE`,
|
|
148
|
+
},
|
|
149
|
+
fix: entry.unavailableFix ?? 'Check provider configuration',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
surface,
|
|
156
|
+
range,
|
|
157
|
+
data,
|
|
158
|
+
meta: {
|
|
159
|
+
queryTimeMs: Date.now() - startMs,
|
|
160
|
+
truncated: Array.isArray(data) && data.length >= limit,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
surface,
|
|
167
|
+
error: {
|
|
168
|
+
message: error instanceof Error ? error.message : String(error),
|
|
169
|
+
code: 'QUERY_FAILED',
|
|
170
|
+
},
|
|
171
|
+
fix: `The ${surface} query failed. Try a different range or check server logs.`,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function queryMany<S extends SurfaceName>(
|
|
177
|
+
surfaces: S[],
|
|
178
|
+
options?: QueryOptions,
|
|
179
|
+
): Promise<{ [K in S]: QueryResult<K> }> {
|
|
180
|
+
const results = await Promise.all(
|
|
181
|
+
surfaces.map(
|
|
182
|
+
async (surface) => [surface, await query(surface, options)] as const,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return Object.fromEntries(results) as { [K in S]: QueryResult<K> }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Returns catalog entries filtered to only surfaces that this engine
|
|
191
|
+
* can actually serve (provider present and function exported).
|
|
192
|
+
*/
|
|
193
|
+
function getCatalog(): SurfaceEntry[] {
|
|
194
|
+
return Object.values(catalog).filter((entry) => {
|
|
195
|
+
const provider = providers[entry.provider]
|
|
196
|
+
return provider && typeof provider[entry.fn] === 'function'
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { query, queryMany, getCatalog }
|
|
201
|
+
}
|