mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
feat(youtube): add comparison, trends, ROI analytics
Add analytics helper functions (calculateComparison, calculateTrends, calculateROI) and extend the analytics API route with three new tabs for video metric comparison, trend analysis, and ROI calculation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e4fea9db4c
commit
9e7b433cd0
3 changed files with 468 additions and 0 deletions
|
|
@ -2,6 +2,12 @@ import { getPayload } from 'payload'
|
||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { subDays } from 'date-fns'
|
import { subDays } from 'date-fns'
|
||||||
|
import {
|
||||||
|
calculateComparison,
|
||||||
|
calculateTrends,
|
||||||
|
calculateROI,
|
||||||
|
} from '@/lib/youtube/analytics-helpers'
|
||||||
|
import type { Metric, VideoWithPerformance } from '@/lib/youtube/analytics-helpers'
|
||||||
|
|
||||||
interface UserWithYouTubeRole {
|
interface UserWithYouTubeRole {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -379,6 +385,80 @@ async function getCommunityData(payload: any, channel: string, period: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_METRICS: Metric[] = [
|
||||||
|
'views', 'likes', 'comments', 'ctr', 'watchTimeMinutes', 'impressions', 'subscribersGained',
|
||||||
|
]
|
||||||
|
|
||||||
|
function isValidMetric(value: string): value is Metric {
|
||||||
|
return VALID_METRICS.includes(value as Metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDocToVideoWithPerformance(doc: any): VideoWithPerformance {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title || '',
|
||||||
|
performance: doc.performance || {},
|
||||||
|
costs: doc.costs || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getComparisonData(payload: any, videoIds: number[], metric: Metric) {
|
||||||
|
if (videoIds.length === 0) {
|
||||||
|
return { comparison: [], metric }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await payload.find({
|
||||||
|
collection: 'youtube-content',
|
||||||
|
where: { id: { in: videoIds } },
|
||||||
|
limit: videoIds.length,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const videos = results.docs.map(mapDocToVideoWithPerformance)
|
||||||
|
return { comparison: calculateComparison(videos, metric), metric }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTrendsData(payload: any, channel: string, metric: Metric) {
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
status: { equals: 'published' },
|
||||||
|
}
|
||||||
|
if (channel !== 'all') {
|
||||||
|
where.channel = { equals: parseInt(channel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await payload.find({
|
||||||
|
collection: 'youtube-content',
|
||||||
|
where,
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
sort: '-actualPublishDate',
|
||||||
|
})
|
||||||
|
|
||||||
|
const videos = results.docs.map(mapDocToVideoWithPerformance)
|
||||||
|
return { trends: calculateTrends(videos, metric), metric, videoCount: videos.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getROIData(payload: any, channel: string) {
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
status: { equals: 'published' },
|
||||||
|
'costs.estimatedProductionCost': { greater_than: 0 },
|
||||||
|
}
|
||||||
|
if (channel !== 'all') {
|
||||||
|
where.channel = { equals: parseInt(channel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await payload.find({
|
||||||
|
collection: 'youtube-content',
|
||||||
|
where,
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
sort: '-actualPublishDate',
|
||||||
|
})
|
||||||
|
|
||||||
|
const videos = results.docs.map(mapDocToVideoWithPerformance)
|
||||||
|
return { roi: calculateROI(videos), videoCount: videos.length }
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config })
|
const payload = await getPayload({ config })
|
||||||
|
|
@ -419,6 +499,32 @@ export async function GET(req: NextRequest) {
|
||||||
case 'community':
|
case 'community':
|
||||||
tabData = await getCommunityData(payload, channel, period)
|
tabData = await getCommunityData(payload, channel, period)
|
||||||
break
|
break
|
||||||
|
case 'comparison': {
|
||||||
|
const videoIdsParam = searchParams.get('videoIds') || ''
|
||||||
|
const videoIds = videoIdsParam
|
||||||
|
.split(',')
|
||||||
|
.map((id) => parseInt(id.trim()))
|
||||||
|
.filter((id) => !isNaN(id))
|
||||||
|
const compMetric = searchParams.get('metric') || 'views'
|
||||||
|
tabData = await getComparisonData(
|
||||||
|
payload,
|
||||||
|
videoIds,
|
||||||
|
isValidMetric(compMetric) ? compMetric : 'views',
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'trends': {
|
||||||
|
const trendMetric = searchParams.get('metric') || 'views'
|
||||||
|
tabData = await getTrendsData(
|
||||||
|
payload,
|
||||||
|
channel,
|
||||||
|
isValidMetric(trendMetric) ? trendMetric : 'views',
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'roi':
|
||||||
|
tabData = await getROIData(payload, channel)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
tabData = await getPerformanceData(payload, channel, period)
|
tabData = await getPerformanceData(payload, channel, period)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
src/lib/youtube/analytics-helpers.ts
Normal file
123
src/lib/youtube/analytics-helpers.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/**
|
||||||
|
* YouTube Analytics Helper Functions
|
||||||
|
*
|
||||||
|
* Provides calculation utilities for video comparison, trend analysis,
|
||||||
|
* and ROI metrics used by the analytics API route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface VideoWithPerformance {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
performance: {
|
||||||
|
views?: number
|
||||||
|
likes?: number
|
||||||
|
comments?: number
|
||||||
|
ctr?: number
|
||||||
|
watchTimeMinutes?: number
|
||||||
|
impressions?: number
|
||||||
|
subscribersGained?: number
|
||||||
|
avgViewPercentage?: number
|
||||||
|
}
|
||||||
|
costs?: {
|
||||||
|
estimatedProductionCost?: number
|
||||||
|
estimatedProductionHours?: number
|
||||||
|
estimatedRevenue?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metric =
|
||||||
|
| 'views'
|
||||||
|
| 'likes'
|
||||||
|
| 'comments'
|
||||||
|
| 'ctr'
|
||||||
|
| 'watchTimeMinutes'
|
||||||
|
| 'impressions'
|
||||||
|
| 'subscribersGained'
|
||||||
|
|
||||||
|
function getMetricValue(video: VideoWithPerformance, metric: Metric): number {
|
||||||
|
return video.performance?.[metric] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateComparison(
|
||||||
|
videos: VideoWithPerformance[],
|
||||||
|
metric: Metric,
|
||||||
|
): Array<{ videoId: number; title: string; value: number }> {
|
||||||
|
return videos.map((v) => ({
|
||||||
|
videoId: v.id,
|
||||||
|
title: v.title,
|
||||||
|
value: getMetricValue(v, metric),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTrends(
|
||||||
|
videos: VideoWithPerformance[],
|
||||||
|
metric: Metric,
|
||||||
|
): {
|
||||||
|
trend: string
|
||||||
|
growth: number
|
||||||
|
average?: number
|
||||||
|
latest?: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
} {
|
||||||
|
if (videos.length < 2) {
|
||||||
|
return { trend: 'insufficient_data', growth: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = videos
|
||||||
|
.map((v) => getMetricValue(v, metric))
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
|
||||||
|
const avg = values.reduce((sum, v) => sum + v, 0) / values.length
|
||||||
|
const latest = values[values.length - 1]
|
||||||
|
const min = values[0]
|
||||||
|
const max = values[values.length - 1]
|
||||||
|
|
||||||
|
let trend: string
|
||||||
|
if (latest > avg) {
|
||||||
|
trend = 'up'
|
||||||
|
} else if (latest < avg) {
|
||||||
|
trend = 'down'
|
||||||
|
} else {
|
||||||
|
trend = 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
const growth = avg > 0 ? ((latest - avg) / avg) * 100 : 0
|
||||||
|
|
||||||
|
return { trend, average: avg, latest, growth, min, max }
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateROI(
|
||||||
|
videos: VideoWithPerformance[],
|
||||||
|
): Array<{
|
||||||
|
videoId: number
|
||||||
|
title: string
|
||||||
|
cost: number
|
||||||
|
revenue: number
|
||||||
|
roi: number
|
||||||
|
cpv: number
|
||||||
|
revenuePerView: number
|
||||||
|
views: number
|
||||||
|
}> {
|
||||||
|
return videos
|
||||||
|
.filter((v) => v.costs?.estimatedProductionCost && v.costs.estimatedProductionCost > 0)
|
||||||
|
.map((v) => {
|
||||||
|
const cost = v.costs!.estimatedProductionCost!
|
||||||
|
const revenue = v.costs?.estimatedRevenue ?? 0
|
||||||
|
const views = v.performance?.views ?? 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoId: v.id,
|
||||||
|
title: v.title,
|
||||||
|
cost,
|
||||||
|
revenue,
|
||||||
|
roi: cost > 0 ? ((revenue - cost) / cost) * 100 : 0,
|
||||||
|
cpv: views > 0 ? cost / views : 0,
|
||||||
|
revenuePerView: views > 0 ? revenue / views : 0,
|
||||||
|
views,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { calculateComparison, calculateTrends, calculateROI }
|
||||||
|
export type { VideoWithPerformance, Metric }
|
||||||
239
tests/unit/youtube/analytics-helpers.unit.spec.ts
Normal file
239
tests/unit/youtube/analytics-helpers.unit.spec.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
/**
|
||||||
|
* YouTube Analytics Helpers Unit Tests
|
||||||
|
*
|
||||||
|
* Tests comparison, trend, and ROI calculation functions
|
||||||
|
* used by the analytics API route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
calculateComparison,
|
||||||
|
calculateTrends,
|
||||||
|
calculateROI,
|
||||||
|
} from '@/lib/youtube/analytics-helpers'
|
||||||
|
import type { VideoWithPerformance } from '@/lib/youtube/analytics-helpers'
|
||||||
|
|
||||||
|
const mockVideos: VideoWithPerformance[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Video A',
|
||||||
|
performance: { views: 1000, likes: 50, ctr: 5.2, watchTimeMinutes: 300 },
|
||||||
|
costs: { estimatedProductionCost: 100, estimatedRevenue: 200 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Video B',
|
||||||
|
performance: { views: 2000, likes: 80, ctr: 3.1, watchTimeMinutes: 600 },
|
||||||
|
costs: { estimatedProductionCost: 150, estimatedRevenue: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Video C',
|
||||||
|
performance: { views: 500, likes: 20, ctr: 7.0, watchTimeMinutes: 150 },
|
||||||
|
costs: { estimatedProductionCost: 50, estimatedRevenue: 75 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('Analytics Helpers', () => {
|
||||||
|
describe('calculateComparison', () => {
|
||||||
|
it('should return comparison metrics for all videos', () => {
|
||||||
|
const result = calculateComparison(mockVideos, 'views')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
expect(result[0]).toEqual({ videoId: 1, title: 'Video A', value: 1000 })
|
||||||
|
expect(result[1]).toEqual({ videoId: 2, title: 'Video B', value: 2000 })
|
||||||
|
expect(result[2]).toEqual({ videoId: 3, title: 'Video C', value: 500 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle different metrics', () => {
|
||||||
|
const result = calculateComparison(mockVideos, 'likes')
|
||||||
|
|
||||||
|
expect(result[0].value).toBe(50)
|
||||||
|
expect(result[1].value).toBe(80)
|
||||||
|
expect(result[2].value).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 0 for missing metric values', () => {
|
||||||
|
const sparse: VideoWithPerformance[] = [
|
||||||
|
{ id: 10, title: 'Sparse', performance: {} },
|
||||||
|
]
|
||||||
|
const result = calculateComparison(sparse, 'impressions')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return an empty array for empty input', () => {
|
||||||
|
const result = calculateComparison([], 'views')
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateTrends', () => {
|
||||||
|
it('should detect an upward trend when latest exceeds average', () => {
|
||||||
|
const result = calculateTrends(mockVideos, 'views')
|
||||||
|
|
||||||
|
expect(result.trend).toBeDefined()
|
||||||
|
expect(result.average).toBeDefined()
|
||||||
|
expect(result.latest).toBeDefined()
|
||||||
|
expect(result.growth).toBeDefined()
|
||||||
|
expect(result.min).toBeDefined()
|
||||||
|
expect(result.max).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return insufficient_data for fewer than 2 videos', () => {
|
||||||
|
const result = calculateTrends([mockVideos[0]], 'views')
|
||||||
|
|
||||||
|
expect(result.trend).toBe('insufficient_data')
|
||||||
|
expect(result.growth).toBe(0)
|
||||||
|
expect(result.average).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return insufficient_data for an empty array', () => {
|
||||||
|
const result = calculateTrends([], 'views')
|
||||||
|
|
||||||
|
expect(result.trend).toBe('insufficient_data')
|
||||||
|
expect(result.growth).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compute correct average and min/max', () => {
|
||||||
|
const result = calculateTrends(mockVideos, 'views')
|
||||||
|
// Values sorted: 500, 1000, 2000
|
||||||
|
// avg = (500 + 1000 + 2000) / 3 = ~1166.67
|
||||||
|
const expectedAvg = (500 + 1000 + 2000) / 3
|
||||||
|
|
||||||
|
expect(result.average).toBeCloseTo(expectedAvg)
|
||||||
|
expect(result.min).toBe(500)
|
||||||
|
expect(result.max).toBe(2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate growth percentage relative to average', () => {
|
||||||
|
const result = calculateTrends(mockVideos, 'views')
|
||||||
|
const expectedAvg = (500 + 1000 + 2000) / 3
|
||||||
|
const expectedGrowth = ((2000 - expectedAvg) / expectedAvg) * 100
|
||||||
|
|
||||||
|
expect(result.growth).toBeCloseTo(expectedGrowth)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return stable trend when latest equals average', () => {
|
||||||
|
const equal: VideoWithPerformance[] = [
|
||||||
|
{ id: 1, title: 'A', performance: { views: 100 } },
|
||||||
|
{ id: 2, title: 'B', performance: { views: 100 } },
|
||||||
|
]
|
||||||
|
const result = calculateTrends(equal, 'views')
|
||||||
|
|
||||||
|
expect(result.trend).toBe('stable')
|
||||||
|
expect(result.growth).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle zero values gracefully', () => {
|
||||||
|
const zeros: VideoWithPerformance[] = [
|
||||||
|
{ id: 1, title: 'A', performance: { views: 0 } },
|
||||||
|
{ id: 2, title: 'B', performance: { views: 0 } },
|
||||||
|
]
|
||||||
|
const result = calculateTrends(zeros, 'views')
|
||||||
|
|
||||||
|
expect(result.trend).toBe('stable')
|
||||||
|
expect(result.growth).toBe(0)
|
||||||
|
expect(result.average).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateROI', () => {
|
||||||
|
it('should calculate ROI for videos with cost data', () => {
|
||||||
|
const result = calculateROI(mockVideos)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compute correct ROI percentage', () => {
|
||||||
|
const result = calculateROI(mockVideos)
|
||||||
|
const videoA = result.find((r) => r.videoId === 1)!
|
||||||
|
|
||||||
|
// ROI = (200 - 100) / 100 * 100 = 100%
|
||||||
|
expect(videoA.roi).toBe(100)
|
||||||
|
expect(videoA.cost).toBe(100)
|
||||||
|
expect(videoA.revenue).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compute correct cost-per-view', () => {
|
||||||
|
const result = calculateROI(mockVideos)
|
||||||
|
const videoA = result.find((r) => r.videoId === 1)!
|
||||||
|
|
||||||
|
// CPV = 100 / 1000 = 0.1
|
||||||
|
expect(videoA.cpv).toBeCloseTo(0.1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compute correct revenue-per-view', () => {
|
||||||
|
const result = calculateROI(mockVideos)
|
||||||
|
const videoB = result.find((r) => r.videoId === 2)!
|
||||||
|
|
||||||
|
// revenuePerView = 100 / 2000 = 0.05
|
||||||
|
expect(videoB.revenuePerView).toBeCloseTo(0.05)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle negative ROI', () => {
|
||||||
|
const result = calculateROI(mockVideos)
|
||||||
|
const videoB = result.find((r) => r.videoId === 2)!
|
||||||
|
|
||||||
|
// ROI = (100 - 150) / 150 * 100 = -33.33%
|
||||||
|
expect(videoB.roi).toBeCloseTo(-33.33, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip videos without cost data', () => {
|
||||||
|
const noCost: VideoWithPerformance[] = [
|
||||||
|
{ id: 10, title: 'No Cost', performance: { views: 500 } },
|
||||||
|
]
|
||||||
|
const result = calculateROI(noCost)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip videos with zero production cost', () => {
|
||||||
|
const zeroCost: VideoWithPerformance[] = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: 'Zero Cost',
|
||||||
|
performance: { views: 500 },
|
||||||
|
costs: { estimatedProductionCost: 0, estimatedRevenue: 100 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = calculateROI(zeroCost)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle videos with zero views', () => {
|
||||||
|
const zeroViews: VideoWithPerformance[] = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: 'Zero Views',
|
||||||
|
performance: { views: 0 },
|
||||||
|
costs: { estimatedProductionCost: 100, estimatedRevenue: 0 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = calculateROI(zeroViews)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].cpv).toBe(0)
|
||||||
|
expect(result[0].revenuePerView).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing revenue', () => {
|
||||||
|
const noRevenue: VideoWithPerformance[] = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: 'No Revenue',
|
||||||
|
performance: { views: 100 },
|
||||||
|
costs: { estimatedProductionCost: 50 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = calculateROI(noRevenue)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].revenue).toBe(0)
|
||||||
|
// ROI = (0 - 50) / 50 * 100 = -100%
|
||||||
|
expect(result[0].roi).toBe(-100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue