@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/dist/api/index.d.ts +158 -0
  3. package/dist/api/index.js +317 -0
  4. package/dist/api/index.js.map +1 -0
  5. package/dist/catalog.d.ts +14 -0
  6. package/dist/catalog.js +209 -0
  7. package/dist/catalog.js.map +1 -0
  8. package/dist/components/index.d.ts +172 -0
  9. package/dist/components/index.js +1258 -0
  10. package/dist/components/index.js.map +1 -0
  11. package/dist/engine.d.ts +20 -0
  12. package/dist/engine.js +350 -0
  13. package/dist/engine.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +353 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/providers/database.d.ts +79 -0
  18. package/dist/providers/database.js +533 -0
  19. package/dist/providers/database.js.map +1 -0
  20. package/dist/providers/derived.d.ts +45 -0
  21. package/dist/providers/derived.js +32 -0
  22. package/dist/providers/derived.js.map +1 -0
  23. package/dist/providers/ga4.d.ts +43 -0
  24. package/dist/providers/ga4.js +220 -0
  25. package/dist/providers/ga4.js.map +1 -0
  26. package/dist/providers/index.d.ts +8 -0
  27. package/dist/providers/index.js +1239 -0
  28. package/dist/providers/index.js.map +1 -0
  29. package/dist/providers/mux.d.ts +103 -0
  30. package/dist/providers/mux.js +241 -0
  31. package/dist/providers/mux.js.map +1 -0
  32. package/dist/providers/survey.d.ts +102 -0
  33. package/dist/providers/survey.js +233 -0
  34. package/dist/providers/survey.js.map +1 -0
  35. package/dist/types.d.ts +303 -0
  36. package/dist/types.js +1 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +101 -0
  39. package/src/api/catalog-handler.ts +321 -0
  40. package/src/api/index.ts +4 -0
  41. package/src/api/token-handler.ts +71 -0
  42. package/src/catalog.ts +223 -0
  43. package/src/components/country-chart.tsx +114 -0
  44. package/src/components/index.ts +5 -0
  45. package/src/components/omnibus-dashboard.tsx +1460 -0
  46. package/src/components/revenue-chart.tsx +251 -0
  47. package/src/components/use-chart-colors.ts +75 -0
  48. package/src/engine.ts +201 -0
  49. package/src/index.ts +7 -0
  50. package/src/providers/database.ts +795 -0
  51. package/src/providers/derived.ts +79 -0
  52. package/src/providers/ga4.ts +173 -0
  53. package/src/providers/index.ts +44 -0
  54. package/src/providers/mux.ts +438 -0
  55. package/src/providers/survey.ts +487 -0
  56. 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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './types'
2
+ export { ANALYTICS_CATALOG, catalog, type SurfaceEntry } from './catalog'
3
+ export {
4
+ createAnalyticsEngine,
5
+ type ProviderMap,
6
+ type AnalyticsProvider,
7
+ } from './engine'