wip
This commit is contained in:
@@ -3,5 +3,11 @@ import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
|
||||
export interface LeagueScoringConfig {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
/**
|
||||
* Optional ID of the scoring preset this configuration was derived from.
|
||||
* Used by application-layer read models to surface preset metadata such as
|
||||
* name and drop policy summaries.
|
||||
*/
|
||||
scoringPresetId?: string;
|
||||
championships: ChampionshipConfig[];
|
||||
}
|
||||
@@ -2,4 +2,5 @@ import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
|
||||
|
||||
export interface ILeagueScoringConfigRepository {
|
||||
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
|
||||
save(config: LeagueScoringConfig): Promise<LeagueScoringConfig>;
|
||||
}
|
||||
@@ -3,4 +3,5 @@ import type { Season } from '../entities/Season';
|
||||
export interface ISeasonRepository {
|
||||
findById(id: string): Promise<Season | null>;
|
||||
findByLeagueId(leagueId: string): Promise<Season[]>;
|
||||
create(season: Season): Promise<Season>;
|
||||
}
|
||||
175
packages/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
175
packages/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
||||
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
|
||||
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
|
||||
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
|
||||
import type { Weekday } from '../value-objects/Weekday';
|
||||
import { weekdayToIndex } from '../value-objects/Weekday';
|
||||
|
||||
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 Error('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 Error('maxRounds must be a positive integer');
|
||||
}
|
||||
|
||||
const recurrence: RecurrenceStrategy = schedule.recurrence;
|
||||
|
||||
if (recurrence.kind === 'monthlyNthWeekday') {
|
||||
return generateMonthlySlots(schedule, maxRounds);
|
||||
}
|
||||
|
||||
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
|
||||
}
|
||||
}
|
||||
14
packages/racing/domain/value-objects/LeagueTimezone.ts
Normal file
14
packages/racing/domain/value-objects/LeagueTimezone.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export class LeagueTimezone {
|
||||
private readonly id: string;
|
||||
|
||||
constructor(id: string) {
|
||||
if (!id || id.trim().length === 0) {
|
||||
throw new Error('LeagueTimezone id must be a non-empty string');
|
||||
}
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Weekday } from './Weekday';
|
||||
|
||||
export class MonthlyRecurrencePattern {
|
||||
readonly ordinal: 1 | 2 | 3 | 4;
|
||||
readonly weekday: Weekday;
|
||||
|
||||
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
|
||||
this.ordinal = ordinal;
|
||||
this.weekday = weekday;
|
||||
}
|
||||
}
|
||||
34
packages/racing/domain/value-objects/RaceTimeOfDay.ts
Normal file
34
packages/racing/domain/value-objects/RaceTimeOfDay.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export class RaceTimeOfDay {
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
|
||||
constructor(hour: number, minute: number) {
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
||||
throw new Error(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
|
||||
}
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||
throw new Error(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
|
||||
}
|
||||
|
||||
this.hour = hour;
|
||||
this.minute = minute;
|
||||
}
|
||||
|
||||
static fromString(value: string): RaceTimeOfDay {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
|
||||
}
|
||||
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
|
||||
return new RaceTimeOfDay(hour, minute);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const hh = this.hour.toString().padStart(2, '0');
|
||||
const mm = this.minute.toString().padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
}
|
||||
53
packages/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
53
packages/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { WeekdaySet } from './WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||
|
||||
export type RecurrenceStrategyKind = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
|
||||
export type WeeklyRecurrence = {
|
||||
kind: 'weekly';
|
||||
weekdays: WeekdaySet;
|
||||
};
|
||||
|
||||
export type EveryNWeeksRecurrence = {
|
||||
kind: 'everyNWeeks';
|
||||
intervalWeeks: number;
|
||||
weekdays: WeekdaySet;
|
||||
};
|
||||
|
||||
export type MonthlyNthWeekdayRecurrence = {
|
||||
kind: 'monthlyNthWeekday';
|
||||
monthlyPattern: MonthlyRecurrencePattern;
|
||||
};
|
||||
|
||||
export type RecurrenceStrategy =
|
||||
| WeeklyRecurrence
|
||||
| EveryNWeeksRecurrence
|
||||
| MonthlyNthWeekdayRecurrence;
|
||||
|
||||
export class RecurrenceStrategyFactory {
|
||||
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
return {
|
||||
kind: 'weekly',
|
||||
weekdays,
|
||||
};
|
||||
}
|
||||
|
||||
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
if (!Number.isInteger(intervalWeeks) || intervalWeeks < 1 || intervalWeeks > 12) {
|
||||
throw new Error('everyNWeeks intervalWeeks must be an integer between 1 and 12');
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'everyNWeeks',
|
||||
intervalWeeks,
|
||||
weekdays,
|
||||
};
|
||||
}
|
||||
|
||||
static monthlyNthWeekday(monthlyPattern: MonthlyRecurrencePattern): RecurrenceStrategy {
|
||||
return {
|
||||
kind: 'monthlyNthWeekday',
|
||||
monthlyPattern,
|
||||
};
|
||||
}
|
||||
}
|
||||
20
packages/racing/domain/value-objects/ScheduledRaceSlot.ts
Normal file
20
packages/racing/domain/value-objects/ScheduledRaceSlot.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LeagueTimezone } from './LeagueTimezone';
|
||||
|
||||
export class ScheduledRaceSlot {
|
||||
readonly roundNumber: number;
|
||||
readonly scheduledAt: Date;
|
||||
readonly timezone: LeagueTimezone;
|
||||
|
||||
constructor(params: { roundNumber: number; scheduledAt: Date; timezone: LeagueTimezone }) {
|
||||
if (!Number.isInteger(params.roundNumber) || params.roundNumber <= 0) {
|
||||
throw new Error('ScheduledRaceSlot.roundNumber must be a positive integer');
|
||||
}
|
||||
if (!(params.scheduledAt instanceof Date) || Number.isNaN(params.scheduledAt.getTime())) {
|
||||
throw new Error('ScheduledRaceSlot.scheduledAt must be a valid Date');
|
||||
}
|
||||
|
||||
this.roundNumber = params.roundNumber;
|
||||
this.scheduledAt = params.scheduledAt;
|
||||
this.timezone = params.timezone;
|
||||
}
|
||||
}
|
||||
36
packages/racing/domain/value-objects/SeasonSchedule.ts
Normal file
36
packages/racing/domain/value-objects/SeasonSchedule.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { RaceTimeOfDay } from './RaceTimeOfDay';
|
||||
import { LeagueTimezone } from './LeagueTimezone';
|
||||
import type { RecurrenceStrategy } from './RecurrenceStrategy';
|
||||
|
||||
export class SeasonSchedule {
|
||||
readonly startDate: Date;
|
||||
readonly timeOfDay: RaceTimeOfDay;
|
||||
readonly timezone: LeagueTimezone;
|
||||
readonly recurrence: RecurrenceStrategy;
|
||||
readonly plannedRounds: number;
|
||||
|
||||
constructor(params: {
|
||||
startDate: Date;
|
||||
timeOfDay: RaceTimeOfDay;
|
||||
timezone: LeagueTimezone;
|
||||
recurrence: RecurrenceStrategy;
|
||||
plannedRounds: number;
|
||||
}) {
|
||||
if (!(params.startDate instanceof Date) || Number.isNaN(params.startDate.getTime())) {
|
||||
throw new Error('SeasonSchedule.startDate must be a valid Date');
|
||||
}
|
||||
if (!Number.isInteger(params.plannedRounds) || params.plannedRounds <= 0) {
|
||||
throw new Error('SeasonSchedule.plannedRounds must be a positive integer');
|
||||
}
|
||||
|
||||
this.startDate = new Date(
|
||||
params.startDate.getFullYear(),
|
||||
params.startDate.getMonth(),
|
||||
params.startDate.getDate(),
|
||||
);
|
||||
this.timeOfDay = params.timeOfDay;
|
||||
this.timezone = params.timezone;
|
||||
this.recurrence = params.recurrence;
|
||||
this.plannedRounds = params.plannedRounds;
|
||||
}
|
||||
}
|
||||
25
packages/racing/domain/value-objects/Weekday.ts
Normal file
25
packages/racing/domain/value-objects/Weekday.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
|
||||
|
||||
export const ALL_WEEKDAYS: Weekday[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
export function weekdayToIndex(day: Weekday): number {
|
||||
switch (day) {
|
||||
case 'Mon':
|
||||
return 1;
|
||||
case 'Tue':
|
||||
return 2;
|
||||
case 'Wed':
|
||||
return 3;
|
||||
case 'Thu':
|
||||
return 4;
|
||||
case 'Fri':
|
||||
return 5;
|
||||
case 'Sat':
|
||||
return 6;
|
||||
case 'Sun':
|
||||
return 7;
|
||||
default:
|
||||
// This should be unreachable because Weekday is a closed union.
|
||||
throw new Error(`Unknown weekday: ${day}`);
|
||||
}
|
||||
}
|
||||
23
packages/racing/domain/value-objects/WeekdaySet.ts
Normal file
23
packages/racing/domain/value-objects/WeekdaySet.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Weekday } from './Weekday';
|
||||
import { weekdayToIndex } from './Weekday';
|
||||
|
||||
export class WeekdaySet {
|
||||
private readonly days: Weekday[];
|
||||
|
||||
constructor(days: Weekday[]) {
|
||||
if (!Array.isArray(days) || days.length === 0) {
|
||||
throw new Error('WeekdaySet requires at least one weekday');
|
||||
}
|
||||
|
||||
const unique = Array.from(new Set(days));
|
||||
this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b));
|
||||
}
|
||||
|
||||
getAll(): Weekday[] {
|
||||
return [...this.days];
|
||||
}
|
||||
|
||||
includes(day: Weekday): boolean {
|
||||
return this.days.includes(day);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user