diff --git a/src/lib/youtube/ConflictDetectionService.ts b/src/lib/youtube/ConflictDetectionService.ts new file mode 100644 index 0000000..cbd8689 --- /dev/null +++ b/src/lib/youtube/ConflictDetectionService.ts @@ -0,0 +1,127 @@ +/** + * Conflict Detection Service + * + * Pure utility for detecting scheduling conflicts in a YouTube + * content calendar. Checks for same-day collisions, weekly frequency + * limits, and weekend scheduling. + */ + +import { parseISO, startOfWeek } from 'date-fns' + +export interface CalendarEvent { + id: number + channelId: number + scheduledDate: string // ISO date + contentType: 'short' | 'longform' | 'premiere' + seriesId?: number + seriesOrder?: number +} + +export interface Conflict { + type: 'same_day' | 'frequency_exceeded' | 'series_order' | 'weekend' + message: string + eventIds: number[] + severity: 'warning' | 'error' +} + +interface ScheduleConfig { + longformPerWeek: number + shortsPerWeek: number +} + +function groupBy(items: T[], keyFn: (item: T) => string): Map { + const map = new Map() + for (const item of items) { + const key = keyFn(item) + const group = map.get(key) ?? [] + group.push(item) + map.set(key, group) + } + return map +} + +function detectSameDayConflicts(events: CalendarEvent[]): Conflict[] { + const conflicts: Conflict[] = [] + const byDayChannel = groupBy( + events, + (e) => `${e.channelId}-${e.scheduledDate.split('T')[0]}`, + ) + + for (const [, dayEvents] of byDayChannel) { + const longforms = dayEvents.filter((e) => e.contentType === 'longform') + if (longforms.length > 1) { + conflicts.push({ + type: 'same_day', + message: `${longforms.length} Longform-Videos am selben Tag geplant`, + eventIds: longforms.map((e) => e.id), + severity: 'error', + }) + } + } + + return conflicts +} + +function detectFrequencyConflicts( + events: CalendarEvent[], + schedule: ScheduleConfig, +): Conflict[] { + const conflicts: Conflict[] = [] + const byWeekChannel = groupBy(events, (e) => { + const weekStart = startOfWeek(parseISO(e.scheduledDate), { weekStartsOn: 1 }) + return `${e.channelId}-${weekStart.toISOString()}` + }) + + for (const [, weekEvents] of byWeekChannel) { + const longforms = weekEvents.filter((e) => e.contentType === 'longform') + const shorts = weekEvents.filter((e) => e.contentType === 'short') + + if (longforms.length > schedule.longformPerWeek) { + conflicts.push({ + type: 'frequency_exceeded', + message: `${longforms.length}/${schedule.longformPerWeek} Longform-Videos diese Woche`, + eventIds: longforms.map((e) => e.id), + severity: 'warning', + }) + } + + if (shorts.length > schedule.shortsPerWeek) { + conflicts.push({ + type: 'frequency_exceeded', + message: `${shorts.length}/${schedule.shortsPerWeek} Shorts diese Woche`, + eventIds: shorts.map((e) => e.id), + severity: 'warning', + }) + } + } + + return conflicts +} + +function detectWeekendConflicts(events: CalendarEvent[]): Conflict[] { + const conflicts: Conflict[] = [] + + for (const event of events) { + const dayOfWeek = parseISO(event.scheduledDate).getDay() + if (dayOfWeek === 0 || dayOfWeek === 6) { + conflicts.push({ + type: 'weekend', + message: 'Video am Wochenende geplant', + eventIds: [event.id], + severity: 'warning', + }) + } + } + + return conflicts +} + +function detectConflicts(events: CalendarEvent[], schedule: ScheduleConfig): Conflict[] { + return [ + ...detectSameDayConflicts(events), + ...detectFrequencyConflicts(events, schedule), + ...detectWeekendConflicts(events), + ] +} + +export { detectConflicts } diff --git a/tests/unit/youtube/conflict-detection.unit.spec.ts b/tests/unit/youtube/conflict-detection.unit.spec.ts new file mode 100644 index 0000000..cc1ed37 --- /dev/null +++ b/tests/unit/youtube/conflict-detection.unit.spec.ts @@ -0,0 +1,152 @@ +/** + * Conflict Detection Service Unit Tests + * + * Tests scheduling conflict detection for same-day collisions, + * weekly frequency limits, and weekend warnings. + */ + +import { describe, it, expect } from 'vitest' +import { detectConflicts, type CalendarEvent } from '@/lib/youtube/ConflictDetectionService' + +const defaultSchedule = { longformPerWeek: 1, shortsPerWeek: 4 } + +describe('ConflictDetectionService', () => { + describe('same-day conflicts', () => { + it('should detect two longform videos on the same day for the same channel', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-03', contentType: 'longform' }, + { id: 2, channelId: 1, scheduledDate: '2026-03-03', contentType: 'longform' }, + ] + + const conflicts = detectConflicts(events, defaultSchedule) + const sameDay = conflicts.find((c) => c.type === 'same_day') + + expect(sameDay).toBeDefined() + expect(sameDay!.severity).toBe('error') + expect(sameDay!.eventIds).toContain(1) + expect(sameDay!.eventIds).toContain(2) + }) + + it('should not flag conflicts for different channels on the same day', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-03', contentType: 'longform' }, + { id: 2, channelId: 2, scheduledDate: '2026-03-03', contentType: 'longform' }, + ] + + const conflicts = detectConflicts(events, defaultSchedule) + const sameDayConflicts = conflicts.filter((c) => c.type === 'same_day') + + expect(sameDayConflicts).toHaveLength(0) + }) + + it('should not flag shorts on the same day as a same-day conflict', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-03', contentType: 'short' }, + { id: 2, channelId: 1, scheduledDate: '2026-03-03', contentType: 'short' }, + ] + + const conflicts = detectConflicts(events, defaultSchedule) + const sameDayConflicts = conflicts.filter((c) => c.type === 'same_day') + + expect(sameDayConflicts).toHaveLength(0) + }) + }) + + describe('weekly frequency', () => { + it('should detect weekly frequency exceeded for longform', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-02', contentType: 'longform' }, + { id: 2, channelId: 1, scheduledDate: '2026-03-04', contentType: 'longform' }, + { id: 3, channelId: 1, scheduledDate: '2026-03-06', contentType: 'longform' }, + ] + + const conflicts = detectConflicts(events, defaultSchedule) + const frequency = conflicts.find((c) => c.type === 'frequency_exceeded') + + expect(frequency).toBeDefined() + expect(frequency!.severity).toBe('warning') + expect(frequency!.eventIds).toHaveLength(3) + }) + + it('should detect weekly frequency exceeded for shorts', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-02', contentType: 'short' }, + { id: 2, channelId: 1, scheduledDate: '2026-03-03', contentType: 'short' }, + { id: 3, channelId: 1, scheduledDate: '2026-03-04', contentType: 'short' }, + { id: 4, channelId: 1, scheduledDate: '2026-03-05', contentType: 'short' }, + { id: 5, channelId: 1, scheduledDate: '2026-03-06', contentType: 'short' }, + ] + + const conflicts = detectConflicts(events, defaultSchedule) + const frequency = conflicts.find( + (c) => c.type === 'frequency_exceeded' && c.message.includes('Shorts'), + ) + + expect(frequency).toBeDefined() + expect(frequency!.eventIds).toHaveLength(5) + }) + + it('should not flag frequency when within limits', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-02', contentType: 'longform' }, + ] + + const conflicts = detectConflicts(events, defaultSchedule) + const frequencyConflicts = conflicts.filter((c) => c.type === 'frequency_exceeded') + + expect(frequencyConflicts).toHaveLength(0) + }) + }) + + describe('weekend warnings', () => { + it('should detect weekend scheduling on Saturday', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-07', contentType: 'longform' }, + ] + + const conflicts = detectConflicts(events, { longformPerWeek: 2, shortsPerWeek: 7 }) + const weekend = conflicts.find((c) => c.type === 'weekend') + + expect(weekend).toBeDefined() + expect(weekend!.severity).toBe('warning') + }) + + it('should detect weekend scheduling on Sunday', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-08', contentType: 'short' }, + ] + + const conflicts = detectConflicts(events, { longformPerWeek: 2, shortsPerWeek: 7 }) + const weekend = conflicts.find((c) => c.type === 'weekend') + + expect(weekend).toBeDefined() + }) + + it('should not flag weekday scheduling', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-02', contentType: 'longform' }, + ] + + const conflicts = detectConflicts(events, { longformPerWeek: 2, shortsPerWeek: 7 }) + const weekendConflicts = conflicts.filter((c) => c.type === 'weekend') + + expect(weekendConflicts).toHaveLength(0) + }) + }) + + describe('edge cases', () => { + it('should return no conflicts for an empty event list', () => { + const conflicts = detectConflicts([], defaultSchedule) + expect(conflicts).toHaveLength(0) + }) + + it('should return no conflicts for a single weekday event within limits', () => { + const events: CalendarEvent[] = [ + { id: 1, channelId: 1, scheduledDate: '2026-03-02', contentType: 'longform' }, + ] + + const conflicts = detectConflicts(events, defaultSchedule) + expect(conflicts).toHaveLength(0) + }) + }) +})