From 9e7b433cd04bd8c1c37644dd5598dc1367a8d8ba Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 14 Feb 2026 13:37:51 +0000 Subject: [PATCH] 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 --- .../(payload)/api/youtube/analytics/route.ts | 106 ++++++++ src/lib/youtube/analytics-helpers.ts | 123 +++++++++ .../youtube/analytics-helpers.unit.spec.ts | 239 ++++++++++++++++++ 3 files changed, 468 insertions(+) create mode 100644 src/lib/youtube/analytics-helpers.ts create mode 100644 tests/unit/youtube/analytics-helpers.unit.spec.ts diff --git a/src/app/(payload)/api/youtube/analytics/route.ts b/src/app/(payload)/api/youtube/analytics/route.ts index 42bd868..f75c268 100644 --- a/src/app/(payload)/api/youtube/analytics/route.ts +++ b/src/app/(payload)/api/youtube/analytics/route.ts @@ -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 = { + 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 = { + 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) } diff --git a/src/lib/youtube/analytics-helpers.ts b/src/lib/youtube/analytics-helpers.ts new file mode 100644 index 0000000..a04ec1e --- /dev/null +++ b/src/lib/youtube/analytics-helpers.ts @@ -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 } diff --git a/tests/unit/youtube/analytics-helpers.unit.spec.ts b/tests/unit/youtube/analytics-helpers.unit.spec.ts new file mode 100644 index 0000000..050ed68 --- /dev/null +++ b/tests/unit/youtube/analytics-helpers.unit.spec.ts @@ -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) + }) + }) +})