feat(youtube): add conflict detection service

Detect scheduling conflicts in the content calendar including same-day
longform collisions, weekly frequency limit violations, and weekend
scheduling warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-14 13:50:24 +00:00
parent d8118528db
commit 2466745e7f
2 changed files with 279 additions and 0 deletions

View file

@ -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<T>(items: T[], keyFn: (item: T) => string): Map<string, T[]> {
const map = new Map<string, T[]>()
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 }

View file

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