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