@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,438 @@
1
+ import { sql } from 'drizzle-orm'
2
+
3
+ // ─── Types ────────────────────────────────────────────────────────────────────
4
+
5
+ export type TimeRange = '7:days' | '30:days' | '90:days'
6
+
7
+ /** Ranges available on the Top Videos table (independent from page-level range) */
8
+ export type VideoTableRange = '24:hours' | '7:days' | '30:days' | '90:days'
9
+
10
+ export interface MuxOverallResponse {
11
+ data: {
12
+ value: number
13
+ total_watch_time: number
14
+ total_playing_time: number
15
+ total_views: number
16
+ global_value: number | null
17
+ }
18
+ timeframe: [number, number]
19
+ }
20
+
21
+ export interface MuxTimeseriesResponse {
22
+ data: [string, number | null, number | null][]
23
+ timeframe: [number, number]
24
+ total_row_count: number
25
+ }
26
+
27
+ export interface MuxBreakdownItem {
28
+ views: number
29
+ value: number
30
+ total_watch_time: number
31
+ total_playing_time: number
32
+ negative_impact: number | null
33
+ field: string
34
+ }
35
+
36
+ export interface MuxBreakdownResponse {
37
+ data: MuxBreakdownItem[]
38
+ timeframe: [number, number]
39
+ total_row_count: number
40
+ }
41
+
42
+ export interface VideoDashboardData {
43
+ overview: {
44
+ totalViews: number
45
+ uniqueViewers: number
46
+ totalWatchTimeMs: number
47
+ totalPlayingTimeMs: number
48
+ viewerExperienceScore: number
49
+ globalExperienceScore: number | null
50
+ }
51
+ watchTimeSeries: {
52
+ date: string
53
+ watchTimeMs: number
54
+ }[]
55
+ topVideos: {
56
+ title: string
57
+ views: number
58
+ watchTimeMs: number
59
+ playingTimeMs: number
60
+ }[]
61
+ countries: {
62
+ country: string
63
+ views: number
64
+ watchTimeMs: number
65
+ }[]
66
+ }
67
+
68
+ export type VideoDetailBreakdowns = {
69
+ countries: {
70
+ country: string
71
+ views: number
72
+ watchTimeMs: number
73
+ }[]
74
+ timeseries: {
75
+ date: string
76
+ views: number
77
+ }[]
78
+ }
79
+
80
+ export interface MuxProviderConfig {
81
+ tokenId: string
82
+ tokenSecret: string
83
+ }
84
+
85
+ export interface MuxDbDeps {
86
+ db: any
87
+ contentResource: any
88
+ }
89
+
90
+ // ─── Factory ─────────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Creates a Mux analytics provider with injected credentials.
94
+ * Optionally accepts db dependencies for thumbnail lookups that
95
+ * require querying ContentResource records.
96
+ *
97
+ * @param config - Mux Data API token configuration
98
+ * @param dbDeps - Optional drizzle db + contentResource table for thumbnail queries
99
+ */
100
+ export function createMuxProvider(
101
+ config: MuxProviderConfig,
102
+ dbDeps?: MuxDbDeps,
103
+ ) {
104
+ const MUX_DATA_BASE = 'https://api.mux.com/data/v1'
105
+
106
+ function getAuthHeader(): string {
107
+ return `Basic ${Buffer.from(
108
+ `${config.tokenId}:${config.tokenSecret}`,
109
+ ).toString('base64')}`
110
+ }
111
+
112
+ async function muxDataFetch<T>(
113
+ path: string,
114
+ params?: Record<string, string | string[]>,
115
+ ): Promise<T> {
116
+ const url = new URL(`${MUX_DATA_BASE}${path}`)
117
+ if (params) {
118
+ for (const [key, value] of Object.entries(params)) {
119
+ if (Array.isArray(value)) {
120
+ for (const v of value) {
121
+ url.searchParams.append(key, v)
122
+ }
123
+ } else {
124
+ url.searchParams.set(key, value)
125
+ }
126
+ }
127
+ }
128
+
129
+ const response = await fetch(url.toString(), {
130
+ headers: {
131
+ Authorization: getAuthHeader(),
132
+ 'Content-Type': 'application/json',
133
+ },
134
+ next: { revalidate: 300 }, // cache 5 minutes
135
+ } as RequestInit)
136
+
137
+ if (!response.ok) {
138
+ throw new Error(
139
+ `Mux Data API error: ${response.status} ${response.statusText}`,
140
+ )
141
+ }
142
+
143
+ return response.json() as Promise<T>
144
+ }
145
+
146
+ // ─── Comparison (unique viewers) ─────────────────────────────────────────
147
+
148
+ interface MuxComparisonItem {
149
+ name: string
150
+ watch_time?: number
151
+ view_count?: number
152
+ unique_viewers?: number
153
+ started_views?: number
154
+ ended_views?: number
155
+ [key: string]: unknown
156
+ }
157
+
158
+ interface MuxComparisonResponse {
159
+ data: MuxComparisonItem[]
160
+ timeframe: [number, number]
161
+ total_row_count: number | null
162
+ }
163
+
164
+ async function getComparisonTotals(timeRange: TimeRange = '30:days') {
165
+ const resp = await muxDataFetch<MuxComparisonResponse>(
166
+ '/metrics/comparison',
167
+ { 'timeframe[]': timeRange },
168
+ )
169
+ const totals = resp.data.find((d) => d.name === 'totals')
170
+ return {
171
+ uniqueViewers: totals?.unique_viewers ?? 0,
172
+ viewCount: totals?.view_count ?? 0,
173
+ watchTimeMs: totals?.watch_time ?? 0,
174
+ }
175
+ }
176
+
177
+ // ─── API Functions ────────────────────────────────────────────────────────
178
+
179
+ async function getViewsOverall(timeRange: TimeRange = '30:days') {
180
+ return muxDataFetch<MuxOverallResponse>('/metrics/views/overall', {
181
+ 'timeframe[]': timeRange,
182
+ })
183
+ }
184
+
185
+ async function getViewerExperienceScore(timeRange: TimeRange = '30:days') {
186
+ return muxDataFetch<MuxOverallResponse>(
187
+ '/metrics/viewer_experience_score/overall',
188
+ { 'timeframe[]': timeRange },
189
+ )
190
+ }
191
+
192
+ async function getViewsTimeseries(timeRange: TimeRange = '30:days') {
193
+ return muxDataFetch<MuxTimeseriesResponse>('/metrics/views/timeseries', {
194
+ 'timeframe[]': timeRange,
195
+ group_by: 'day',
196
+ })
197
+ }
198
+
199
+ /**
200
+ * Fetch watch time (playing_time) timeseries grouped by day.
201
+ * Mux returns [date, totalPlayingTimeMs, viewCount] tuples.
202
+ * The value IS the total playing time in ms (not the average).
203
+ */
204
+ async function getWatchTimeTimeseries(timeRange: TimeRange = '30:days') {
205
+ return muxDataFetch<MuxTimeseriesResponse>(
206
+ '/metrics/playing_time/timeseries',
207
+ {
208
+ 'timeframe[]': timeRange,
209
+ group_by: 'day',
210
+ },
211
+ )
212
+ }
213
+
214
+ async function getVideoBreakdown(
215
+ timeRange: TimeRange = '30:days',
216
+ limit: number = 25,
217
+ ) {
218
+ return muxDataFetch<MuxBreakdownResponse>('/metrics/views/breakdown', {
219
+ 'timeframe[]': timeRange,
220
+ group_by: 'video_title',
221
+ order_by: 'views',
222
+ order_direction: 'desc',
223
+ limit: String(limit),
224
+ })
225
+ }
226
+
227
+ /**
228
+ * Standalone video breakdown fetcher for the Top Videos table's
229
+ * independent time-range tabs. Accepts VideoTableRange.
230
+ */
231
+ async function getVideoBreakdownForRange(
232
+ timeRange: VideoTableRange,
233
+ limit: number = 50,
234
+ ) {
235
+ return muxDataFetch<MuxBreakdownResponse>('/metrics/views/breakdown', {
236
+ 'timeframe[]': timeRange,
237
+ group_by: 'video_title',
238
+ order_by: 'views',
239
+ order_direction: 'desc',
240
+ limit: String(limit),
241
+ })
242
+ }
243
+
244
+ async function getCountryBreakdown(
245
+ timeRange: TimeRange = '30:days',
246
+ limit: number = 10,
247
+ ) {
248
+ return muxDataFetch<MuxBreakdownResponse>('/metrics/views/breakdown', {
249
+ 'timeframe[]': timeRange,
250
+ group_by: 'country',
251
+ order_by: 'views',
252
+ order_direction: 'desc',
253
+ limit: String(limit),
254
+ })
255
+ }
256
+
257
+ async function getVideoDetailBreakdowns(
258
+ videoTitle: string,
259
+ timeRange: TimeRange = '30:days',
260
+ ): Promise<VideoDetailBreakdowns> {
261
+ const filter = `video_title:${videoTitle}`
262
+ const [countries, timeseries] = await Promise.all([
263
+ muxDataFetch<MuxBreakdownResponse>('/metrics/views/breakdown', {
264
+ 'timeframe[]': timeRange,
265
+ group_by: 'country',
266
+ order_by: 'views',
267
+ order_direction: 'desc',
268
+ limit: '8',
269
+ 'filters[]': filter,
270
+ }),
271
+ muxDataFetch<MuxTimeseriesResponse>('/metrics/views/timeseries', {
272
+ 'timeframe[]': timeRange,
273
+ group_by: 'day',
274
+ 'filters[]': filter,
275
+ }),
276
+ ])
277
+
278
+ return {
279
+ countries: countries.data.map((c) => ({
280
+ country: c.field,
281
+ views: c.views,
282
+ watchTimeMs: c.total_watch_time,
283
+ })),
284
+ timeseries: timeseries.data.map(([date, value]) => ({
285
+ date,
286
+ views: value ?? 0,
287
+ })),
288
+ }
289
+ }
290
+
291
+ // ─── Aggregate fetcher ────────────────────────────────────────────────────
292
+
293
+ async function getVideoDashboardData(
294
+ timeRange: TimeRange = '30:days',
295
+ ): Promise<VideoDashboardData> {
296
+ const [views, experience, comparison, watchTime, videos, countries] =
297
+ await Promise.all([
298
+ getViewsOverall(timeRange),
299
+ getViewerExperienceScore(timeRange),
300
+ getComparisonTotals(timeRange),
301
+ getWatchTimeTimeseries(timeRange),
302
+ getVideoBreakdown(timeRange, 50),
303
+ getCountryBreakdown(timeRange, 15),
304
+ ])
305
+
306
+ return {
307
+ overview: {
308
+ totalViews: views.data.total_views,
309
+ uniqueViewers: comparison.uniqueViewers,
310
+ totalWatchTimeMs: views.data.total_watch_time,
311
+ totalPlayingTimeMs: views.data.total_playing_time,
312
+ viewerExperienceScore: experience.data.value,
313
+ globalExperienceScore: experience.data.global_value,
314
+ },
315
+ watchTimeSeries: watchTime.data.map(([date, totalMs]) => ({
316
+ date,
317
+ // Mux playing_time timeseries: value is total playing time in ms
318
+ watchTimeMs: totalMs ?? 0,
319
+ })),
320
+ topVideos: videos.data
321
+ .filter((v) => v.field !== '')
322
+ .map((v) => ({
323
+ title: v.field,
324
+ views: v.views,
325
+ watchTimeMs: v.total_watch_time,
326
+ playingTimeMs: v.total_playing_time,
327
+ })),
328
+ countries: countries.data.map((c) => ({
329
+ country: c.field,
330
+ views: c.views,
331
+ watchTimeMs: c.total_watch_time,
332
+ })),
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Resolve video titles to Mux thumbnail URLs.
338
+ * 1. Break down by video_id to get ContentResource IDs for top videos
339
+ * 2. Look up playback IDs from ContentResource.fields.muxPlaybackId
340
+ * 3. Build image.mux.com thumbnail URLs
341
+ * Returns Record<title, thumbnailUrl>.
342
+ * Requires dbDeps to be provided at factory construction time.
343
+ */
344
+ async function getVideoThumbnails(
345
+ timeRange: TimeRange = '30:days',
346
+ limit: number = 10,
347
+ ): Promise<Record<string, string>> {
348
+ if (!dbDeps) {
349
+ throw new Error(
350
+ 'getVideoThumbnails requires dbDeps (db + contentResource) ' +
351
+ 'to be provided to createMuxProvider',
352
+ )
353
+ }
354
+
355
+ const { db, contentResource } = dbDeps
356
+
357
+ // Get breakdown by both video_id and video_title
358
+ const [byId, byTitle] = await Promise.all([
359
+ muxDataFetch<MuxBreakdownResponse>('/metrics/views/breakdown', {
360
+ 'timeframe[]': timeRange,
361
+ group_by: 'video_id',
362
+ order_by: 'views',
363
+ order_direction: 'desc',
364
+ limit: String(limit),
365
+ }),
366
+ muxDataFetch<MuxBreakdownResponse>('/metrics/views/breakdown', {
367
+ 'timeframe[]': timeRange,
368
+ group_by: 'video_title',
369
+ order_by: 'views',
370
+ order_direction: 'desc',
371
+ limit: String(limit),
372
+ }),
373
+ ])
374
+
375
+ // video_id breakdown gives ContentResource IDs — look up playback IDs
376
+ const videoIds = byId.data
377
+ .filter((v) => v.field && v.field !== '')
378
+ .map((v) => v.field)
379
+
380
+ if (videoIds.length === 0) return {}
381
+
382
+ const rows = await db
383
+ .select({
384
+ id: contentResource.id,
385
+ playbackId:
386
+ sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.muxPlaybackId'))`.as(
387
+ 'playbackId',
388
+ ),
389
+ })
390
+ .from(contentResource)
391
+ .where(
392
+ sql`${contentResource.id} IN (${sql.join(
393
+ videoIds.map((id: string) => sql`${id}`),
394
+ sql`, `,
395
+ )})`,
396
+ )
397
+
398
+ // Map video_id → playbackId
399
+ const idToPlayback = new Map<string, string>()
400
+ for (const r of rows) {
401
+ if (r.playbackId && r.playbackId !== 'null') {
402
+ idToPlayback.set(r.id, r.playbackId)
403
+ }
404
+ }
405
+
406
+ // Match by position: byId and byTitle are both sorted by views desc,
407
+ // so position i in byId corresponds to position i in byTitle
408
+ const result: Record<string, string> = {}
409
+ for (let i = 0; i < Math.min(byId.data.length, byTitle.data.length); i++) {
410
+ const videoId = byId.data[i]?.field
411
+ const title = byTitle.data[i]?.field
412
+ if (!videoId || !title) continue
413
+ const playbackId = idToPlayback.get(videoId)
414
+ if (playbackId) {
415
+ result[title] =
416
+ `https://image.mux.com/${playbackId}/thumbnail.jpg?width=240&height=135&fit_mode=smartcrop`
417
+ }
418
+ }
419
+
420
+ return result
421
+ }
422
+
423
+ return {
424
+ getComparisonTotals,
425
+ getViewsOverall,
426
+ getViewerExperienceScore,
427
+ getViewsTimeseries,
428
+ getWatchTimeTimeseries,
429
+ getVideoBreakdown,
430
+ getVideoBreakdownForRange,
431
+ getCountryBreakdown,
432
+ getVideoDetailBreakdowns,
433
+ getVideoDashboardData,
434
+ getVideoThumbnails,
435
+ }
436
+ }
437
+
438
+ export type MuxAnalyticsProvider = ReturnType<typeof createMuxProvider>