Files
gridpilot.gg/core/racing/domain/services/SeasonScheduleGenerator.ts
2026-01-16 19:46:49 +01:00

186 lines
5.3 KiB
TypeScript

import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
import type { DomainCalculationService } from '@core/shared/domain/Service';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
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.props.kind === 'weekly' || recurrence.props.kind === 'everyNWeeks'
? recurrence.props.weekdays.getAll()
: [];
if (weekdays.length === 0) {
throw new RacingDomainValidationError('RecurrenceStrategy has no weekdays');
}
const intervalWeeks = recurrence.props.kind === 'everyNWeeks' ? recurrence.props.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.props.kind !== 'monthlyNthWeekday') {
return result;
}
const { ordinal, weekday } = recurrence.props.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.props.kind === 'monthlyNthWeekday') {
return generateMonthlySlots(schedule, maxRounds);
}
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
}
}
export class SeasonScheduleGeneratorService
implements DomainCalculationService<SeasonSchedule, ScheduledRaceSlot[]>
{
calculate(schedule: SeasonSchedule): ScheduledRaceSlot[] {
return SeasonScheduleGenerator.generateSlots(schedule);
}
}