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:
Martin Porwoll 2026-02-14 13:37:51 +00:00
parent e4fea9db4c
commit 9e7b433cd0
3 changed files with 468 additions and 0 deletions

View file

@ -2,6 +2,12 @@ import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
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 {
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) {
try {
const payload = await getPayload({ config })
@ -419,6 +499,32 @@ export async function GET(req: NextRequest) {
case 'community':
tabData = await getCommunityData(payload, channel, period)
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:
tabData = await getPerformanceData(payload, channel, period)
}

View 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 }

View 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)
})
})
})