@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,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>
|