import { SeasonSchedule } from '../value-objects/SeasonSchedule'; import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot'; import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy'; import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError'; import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay'; import type { Weekday } from '../types/Weekday'; import { weekdayToIndex } from '../types/Weekday'; import type { IDomainCalculationService } from '@gridpilot/shared/domain'; function cloneDate(date: Date): Date { return new Date(date.getTime()); } function addDays(date: Date, days: number): Date { const d = cloneDate(date); d.setDate(d.getDate() + days); return d; } function addWeeks(date: Date, weeks: number): Date { return addDays(date, weeks * 7); } function addMonths(date: Date, months: number): Date { const d = cloneDate(date); const targetMonth = d.getMonth() + months; d.setMonth(targetMonth); return d; } function applyTimeOfDay(baseDate: Date, timeOfDay: RaceTimeOfDay): Date { const d = new Date( baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), timeOfDay.hour, timeOfDay.minute, 0, 0, ); return d; } // Treat Monday as 1 ... Sunday as 7 function getCalendarWeekdayIndex(date: Date): number { const jsDay = date.getDay(); // 0=Sun ... 6=Sat if (jsDay === 0) { return 7; } return jsDay; } function weekdayToCalendarOffset(anchor: Date, target: Weekday): number { const anchorIndex = getCalendarWeekdayIndex(anchor); const targetIndex = weekdayToIndex(target); return targetIndex - anchorIndex; } function generateWeeklyOrEveryNWeeksSlots( schedule: SeasonSchedule, maxRounds: number, ): ScheduledRaceSlot[] { const result: ScheduledRaceSlot[] = []; const recurrence = schedule.recurrence; const weekdays = recurrence.kind === 'weekly' || recurrence.kind === 'everyNWeeks' ? recurrence.weekdays.getAll() : []; if (weekdays.length === 0) { throw new RacingDomainValidationError('RecurrenceStrategy has no weekdays'); } const intervalWeeks = recurrence.kind === 'everyNWeeks' ? recurrence.intervalWeeks : 1; let anchorWeekStart = cloneDate(schedule.startDate); let roundNumber = 1; while (result.length < maxRounds) { for (const weekday of weekdays) { const offset = weekdayToCalendarOffset(anchorWeekStart, weekday); const candidateDate = addDays(anchorWeekStart, offset); if (candidateDate < schedule.startDate) { continue; } const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay); result.push( new ScheduledRaceSlot({ roundNumber, scheduledAt, timezone: schedule.timezone, }), ); roundNumber += 1; if (result.length >= maxRounds) { break; } } anchorWeekStart = addWeeks(anchorWeekStart, intervalWeeks); } return result; } function findNthWeekdayOfMonth(base: Date, ordinal: 1 | 2 | 3 | 4, weekday: Weekday): Date { const firstOfMonth = new Date(base.getFullYear(), base.getMonth(), 1); const firstIndex = getCalendarWeekdayIndex(firstOfMonth); const targetIndex = weekdayToIndex(weekday); let offset = targetIndex - firstIndex; if (offset < 0) { offset += 7; } const dayOfMonth = 1 + offset + (ordinal - 1) * 7; return new Date(base.getFullYear(), base.getMonth(), dayOfMonth); } function generateMonthlySlots(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] { const result: ScheduledRaceSlot[] = []; const recurrence = schedule.recurrence; if (recurrence.kind !== 'monthlyNthWeekday') { return result; } const { ordinal, weekday } = recurrence.monthlyPattern; let currentMonthDate = new Date( schedule.startDate.getFullYear(), schedule.startDate.getMonth(), 1, ); let roundNumber = 1; while (result.length < maxRounds) { const candidateDate = findNthWeekdayOfMonth(currentMonthDate, ordinal, weekday); if (candidateDate >= schedule.startDate) { const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay); result.push( new ScheduledRaceSlot({ roundNumber, scheduledAt, timezone: schedule.timezone, }), ); roundNumber += 1; } currentMonthDate = addMonths(currentMonthDate, 1); } return result; } export class SeasonScheduleGenerator { static generateSlots(schedule: SeasonSchedule): ScheduledRaceSlot[] { return this.generateSlotsUpTo(schedule, schedule.plannedRounds); } static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] { if (!Number.isInteger(maxRounds) || maxRounds <= 0) { throw new RacingDomainValidationError('maxRounds must be a positive integer'); } const recurrence: RecurrenceStrategy = schedule.recurrence; if (recurrence.kind === 'monthlyNthWeekday') { return generateMonthlySlots(schedule, maxRounds); } return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds); } } export class SeasonScheduleGeneratorService implements IDomainCalculationService { calculate(schedule: SeasonSchedule): ScheduledRaceSlot[] { return SeasonScheduleGenerator.generateSlots(schedule); } }