186 lines
5.3 KiB
TypeScript
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);
|
|
}
|
|
} |