mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
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:
parent
d8118528db
commit
2466745e7f
2 changed files with 279 additions and 0 deletions
127
src/lib/youtube/ConflictDetectionService.ts
Normal file
127
src/lib/youtube/ConflictDetectionService.ts
Normal 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 }
|
||||
152
tests/unit/youtube/conflict-detection.unit.spec.ts
Normal file
152
tests/unit/youtube/conflict-detection.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue