diff --git a/src/abstract/lib/GroupBuilder/AvailableGroupBuilder.test.ts b/src/abstract/lib/GroupBuilder/AvailableGroupBuilder.test.ts index 809719544..e7c216c3f 100644 --- a/src/abstract/lib/GroupBuilder/AvailableGroupBuilder.test.ts +++ b/src/abstract/lib/GroupBuilder/AvailableGroupBuilder.test.ts @@ -73,8 +73,13 @@ const getScheduledEventEntity = (settings: { const section = getScheduledSection(); const scheduledAt = new Date(scheduledAtDay); - scheduledAt.setHours(section.timeFrom!.hours); - scheduledAt.setMinutes(section.timeFrom!.minutes); + + if (section.timeFrom === null) { + throw new Error('[getScheduledEventEntity]: timeFrom is null'); + } + + scheduledAt.setHours(section.timeFrom.hours); + scheduledAt.setMinutes(section.timeFrom.minutes); const result: EventEntity = { entity: getActivity(), diff --git a/src/abstract/lib/GroupBuilder/AvailableGroupEvaluator.ts b/src/abstract/lib/GroupBuilder/AvailableGroupEvaluator.ts index 231d17cc4..ea208ad8b 100644 --- a/src/abstract/lib/GroupBuilder/AvailableGroupEvaluator.ts +++ b/src/abstract/lib/GroupBuilder/AvailableGroupEvaluator.ts @@ -31,10 +31,22 @@ export class AvailableGroupEvaluator implements IEvaluator { const now = this.utility.getNow(); + if (event.availability.timeFrom === null) { + throw new Error('[isValidWhenNoSpreadAndNoAccessBeforeStartTime] timeFrom is null'); + } + + if (event.availability.timeTo === null) { + throw new Error('[isValidWhenNoSpreadAndNoAccessBeforeStartTime] timeTo is null'); + } + + if (event.scheduledAt === null) { + throw new Error('[isValidWhenNoSpreadAndNoAccessBeforeStartTime] scheduledAt is null'); + } + const isCurrentTimeInTimeWindow = isTimeInInterval({ timeToCheck: getHourMinute(now), - intervalFrom: event.availability.timeFrom!, - intervalTo: event.availability.timeTo!, + intervalFrom: event.availability.timeFrom, + intervalTo: event.availability.timeTo, including: 'from', }); @@ -45,7 +57,7 @@ export class AvailableGroupEvaluator implements IEvaluator { const isCompletedToday = !!endAt && this.utility.isToday(new Date(endAt)); return ( - isScheduledToday && now > event.scheduledAt! && isCurrentTimeInTimeWindow && !isCompletedToday + isScheduledToday && now > event.scheduledAt && isCurrentTimeInTimeWindow && !isCompletedToday ); } diff --git a/src/abstract/lib/GroupBuilder/GroupUtility.ts b/src/abstract/lib/GroupBuilder/GroupUtility.ts index 374783021..8455cb37d 100644 --- a/src/abstract/lib/GroupBuilder/GroupUtility.ts +++ b/src/abstract/lib/GroupBuilder/GroupUtility.ts @@ -31,9 +31,17 @@ export class GroupUtility { } private getStartedAt(eventActivity: EventEntity): Date { - const record = this.getProgressRecord(eventActivity)!; + const record = this.getProgressRecord(eventActivity); + + if (record === null) { + throw new Error('[getStartedAt] Progress record is null'); + } + + if (record.startAt === null) { + throw new Error('[getStartedAt] Progress record startAt is null'); + } - return new Date(record.startAt!); + return new Date(record.startAt); } private getAllowedTimeInterval( @@ -43,8 +51,16 @@ export class GroupUtility { ): DatesFromTo { const { event } = eventActivity; - const { hours: hoursFrom, minutes: minutesFrom } = event.availability.timeFrom!; - const { hours: hoursTo, minutes: minutesTo } = event.availability.timeTo!; + if (event.availability.timeFrom === null) { + throw new Error('[getAllowedTimeInterval] timeFrom is null'); + } + + if (event.availability.timeTo === null) { + throw new Error('[getAllowedTimeInterval] timeTo is null'); + } + + const { hours: hoursFrom, minutes: minutesFrom } = event.availability.timeFrom; + const { hours: hoursTo, minutes: minutesTo } = event.availability.timeTo; if (scheduledWhen === 'today') { const allowedFrom = this.getToday(); @@ -154,25 +170,41 @@ export class GroupUtility { let from = this.getToday(); + if (timeFrom === null) { + throw new Error('[getVoidInterval] timeFrom is null'); + } + + if (timeTo === null) { + throw new Error('[getVoidInterval] timeTo is null'); + } + if (buildFrom) { from = this.getToday(); - from.setHours(timeTo!.hours); - from.setMinutes(timeTo!.minutes); + from.setHours(timeTo.hours); + from.setMinutes(timeTo.minutes); } const to = this.getToday(); - to.setHours(timeFrom!.hours); - to.setMinutes(timeFrom!.minutes); + to.setHours(timeFrom.hours); + to.setMinutes(timeFrom.minutes); return { from, to }; } public isSpreadToNextDay(event: ScheduleEvent): boolean { + if (event.availability.timeFrom === null) { + throw new Error('[isSpreadToNextDay] timeFrom is null'); + } + + if (event.availability.timeTo === null) { + throw new Error('[isSpreadToNextDay] timeTo is null'); + } + return ( event.availability.availabilityType === AvailabilityLabelType.ScheduledAccess && isSourceLess({ - timeSource: event.availability.timeTo!, - timeTarget: event.availability.timeFrom!, + timeSource: event.availability.timeTo, + timeTarget: event.availability.timeFrom, }) ); } @@ -188,7 +220,7 @@ export class GroupUtility { isAccessBeforeStartTime, ); - const completedAt = this.getCompletedAt(eventActivity)!; + const completedAt = this.getCompletedAt(eventActivity); if (!completedAt) { return false; @@ -272,7 +304,11 @@ export class GroupUtility { public getTimeToComplete(eventActivity: EventEntity): HourMinute | null { const { event } = eventActivity; - const timer = event.timers.timer!; + const timer = event.timers.timer; + + if (timer === null) { + throw new Error('[getTimeToComplete] Timer is null'); + } const startedTime = this.getStartedAt(eventActivity); diff --git a/src/abstract/lib/GroupBuilder/ScheduledGroupEvaluator.test.ts b/src/abstract/lib/GroupBuilder/ScheduledGroupEvaluator.test.ts index 85abdc6a2..22b3e2272 100644 --- a/src/abstract/lib/GroupBuilder/ScheduledGroupEvaluator.test.ts +++ b/src/abstract/lib/GroupBuilder/ScheduledGroupEvaluator.test.ts @@ -72,8 +72,13 @@ const getScheduledEventEntity = (settings: { const section = getScheduledSection(); const scheduledAt = new Date(scheduledAtDay); - scheduledAt.setHours(section.timeFrom!.hours); - scheduledAt.setMinutes(section.timeFrom!.minutes); + + if (section.timeFrom === null) { + throw new Error('[getScheduledEventEntity]: timeFrom is null'); + } + + scheduledAt.setHours(section.timeFrom.hours); + scheduledAt.setMinutes(section.timeFrom.minutes); const result: EventEntity = { entity: getActivity(), diff --git a/src/entities/event/model/operations/ScheduledDateCalculator.monthly.test.ts b/src/entities/event/model/operations/ScheduledDateCalculator.monthly.test.ts new file mode 100644 index 000000000..ed495ee91 --- /dev/null +++ b/src/entities/event/model/operations/ScheduledDateCalculator.monthly.test.ts @@ -0,0 +1,351 @@ +import { addDays, addMonths, startOfDay, subDays, subMonths } from 'date-fns'; + +import { ScheduledDateCalculator } from './ScheduledDateCalculator'; +import { AvailabilityLabelType, PeriodicityType, ScheduleEvent } from '../../lib'; + +describe('ScheduledDateCalculator: test monthly events', () => { + const getScheduledDateCalculator = (now: Date) => { + const instance = new ScheduledDateCalculator(); + instance.getNow = jest.fn().mockReturnValue(now); + return instance; + }; + + const getMonthlyEvent = (): ScheduleEvent => { + const scheduleEvent: ScheduleEvent = { + id: 'eventTestId', + entityId: 'entityTestId', + availability: { + availabilityType: AvailabilityLabelType.ScheduledAccess, + periodicityType: PeriodicityType.Monthly, + timeFrom: { hours: 14, minutes: 0 }, + timeTo: { hours: 18, minutes: 30 }, + startDate: null, + endDate: null, + allowAccessBeforeFromTime: false, + oneTimeCompletion: false, + }, + selectedDate: null, + scheduledAt: null, + timers: { + timer: null, + idleTimer: null, + }, + notificationSettings: { + notifications: [], + }, + }; + + return scheduleEvent; + }; + + describe('Test regular cases', () => { + const now: Date = new Date(2024, 0, 25); + + const scheduledDateCalculator = getScheduledDateCalculator(now); + + it("Should return today's start of day when selected day is today and is covered by start-end dates and timeFrom is reset", () => { + const event = getMonthlyEvent(); + event.availability.startDate = subDays(startOfDay(now), 2); + event.availability.endDate = addDays(startOfDay(now), 2); + event.availability.timeFrom = { hours: 0, minutes: 0 }; + event.selectedDate = startOfDay(now); + + const resultDate = scheduledDateCalculator.calculate(event, false); + + const expectedDate = startOfDay(now); + + expect(resultDate).toEqual(expectedDate); + }); + + it('Should return today with timeFrom when selected day is today and covered by start-end dates and timeFrom is set', () => { + const event = getMonthlyEvent(); + event.availability.startDate = subDays(startOfDay(now), 2); + event.availability.endDate = addDays(startOfDay(now), 2); + event.selectedDate = startOfDay(now); + + const resultDate = scheduledDateCalculator.calculate(event, false); + + const expectedDate = startOfDay(now); + + if (event.availability.timeFrom === null) { + throw new Error( + '[timeFrom is null] Should return today with timeFrom when selected day is today and covered by start-end dates and timeFrom is set', + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + }); + + it('Should return date today with timeFrom when selected day is (today + or - 1 month) and timeFrom is set and date start/end are (today -/+ 2 months)', () => { + const event = getMonthlyEvent(); + event.availability.startDate = subMonths(startOfDay(now), 2); + event.availability.endDate = addMonths(startOfDay(now), 2); + event.selectedDate = addMonths(startOfDay(now), 1); + + let resultDate = scheduledDateCalculator.calculate(event, false); + + const expectedDate = startOfDay(now); + + if (event.availability.timeFrom === null) { + throw new Error( + '[timeFrom is null] Should return date today with timeFrom when selected day is (today + or - 1 month) and timeFrom is set and date start/end are (today -/+ 2 months)', + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + + event.selectedDate = subMonths(startOfDay(now), 1); + + resultDate = scheduledDateCalculator.calculate(event, false); + + expect(resultDate).toEqual(expectedDate); + }); + + it('Should return a date equal to (tomorrow - 1 month) with set/not set timeFrom when selected date is tomorrow and start/end dates are (today +1/+25 days)', () => { + const event = getMonthlyEvent(); + event.availability.startDate = addDays(startOfDay(now), 1); + event.availability.endDate = addDays(startOfDay(now), 25); + event.selectedDate = addDays(startOfDay(now), 1); + + let resultDate = scheduledDateCalculator.calculate(event, false); + + let expectedDate = subMonths(addDays(startOfDay(now), 1), 1); + + if (event.availability.timeFrom === null) { + throw new Error( + '[timeFrom is null] Should return a date equal to (tomorrow - 1 month) with set/not set timeFrom when selected date is tomorrow and start/end dates are (today +1/+25 days)', + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + + event.availability.timeFrom = { hours: 0, minutes: 0 }; + + resultDate = scheduledDateCalculator.calculate(event, false); + + expectedDate = subMonths(addDays(startOfDay(now), 1), 1); + + expect(resultDate).toEqual(expectedDate); + }); + + it('Should return a date equal to endDate with set/not set timeFrom when selected date is yesterday and start/end dates are (today -25/-1 days)', () => { + const event = getMonthlyEvent(); + event.availability.startDate = subDays(startOfDay(now), 25); + event.availability.endDate = subDays(startOfDay(now), 1); + event.selectedDate = subDays(startOfDay(now), 1); + + let resultDate = scheduledDateCalculator.calculate(event, false); + + let expectedDate = subDays(startOfDay(now), 1); + + if (event.availability.timeFrom === null) { + throw new Error( + '[timeFrom is null] Should return a date equal to endDate with set/not set timeFrom when selected date is yesterday and start/end dates are (today -25/-1 days)', + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + + event.availability.timeFrom = { hours: 0, minutes: 0 }; + + resultDate = scheduledDateCalculator.calculate(event, false); + + expectedDate = subDays(startOfDay(now), 1); + + expect(resultDate).toEqual(expectedDate); + }); + + it('Should return a date equal to endDate with set/not set timeFrom when selected date is yesterday and start/end dates are (today -55/-1 days)', () => { + // -2 todo + const event = getMonthlyEvent(); + event.availability.startDate = subDays(startOfDay(now), 55); + event.availability.endDate = subDays(startOfDay(now), 1); + event.selectedDate = subDays(startOfDay(now), 1); + + let resultDate = scheduledDateCalculator.calculate(event, false); + + let expectedDate = subDays(startOfDay(now), 1); + + if (event.availability.timeFrom === null) { + throw new Error( + '[timeFrom is null] Should return a date equal to endDate with set/not set timeFrom when selected date is yesterday and start/end dates are (today -55/-1 days)', + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + + event.availability.timeFrom = { hours: 0, minutes: 0 }; + + resultDate = scheduledDateCalculator.calculate(event, false); + + expectedDate = subDays(startOfDay(now), 1); + + expect(resultDate).toEqual(expectedDate); + }); + + it('Should return (today + 10 days - 1 month days with timeFrom) when timeFrom is set and selected date is (today + 10 days) and start/end dates -/+ 50 days', () => { + const event = getMonthlyEvent(); + event.availability.startDate = subDays(startOfDay(now), 50); + event.availability.endDate = addDays(startOfDay(now), 50); + event.selectedDate = addDays(startOfDay(now), 10); + + const resultDate = scheduledDateCalculator.calculate(event, false); + + const expectedDate = subMonths(addDays(startOfDay(now), 10), 1); + + if (event.availability.timeFrom === null) { + throw new Error( + '[timeFrom is null] Should return (today + 10 days - 1 month days with timeFrom) when timeFrom is set and selected date is (today + 10 days) and start/end dates -/+ 50 days', + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + }); + + it('Should return (today - 4 days with timeFrom) when timeFrom is set and selected date is (today - 4 days) and start/end dates -/+ 50 days', () => { + const event = getMonthlyEvent(); + event.availability.startDate = subDays(startOfDay(now), 50); + event.availability.endDate = addDays(startOfDay(now), 50); + event.selectedDate = subDays(startOfDay(now), 4); + + const resultDate = scheduledDateCalculator.calculate(event, false); + + const expectedDate = subDays(startOfDay(now), 4); + + if (event.availability.timeFrom === null) { + throw new Error( + '[timeFrom is null] Should return (today - 4 days with timeFrom) when timeFrom is set and selected date is (today - 4 days) and start/end dates -/+ 50 days', + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + }); + + it('Should return selected date with set time when selected date is (today - 1 days) and start/end date earlier than (today -1 day)', () => { + const event = getMonthlyEvent(); + event.availability.startDate = subDays(startOfDay(now), 56); + event.availability.endDate = subDays(startOfDay(now), 3); + event.selectedDate = subDays(startOfDay(now), 1); + + const resultDate = scheduledDateCalculator.calculate(event, false); + + const expectedDate = new Date(event.selectedDate); + + if (event.availability.timeFrom === null) { + throw new Error( + '[timeFrom is null] Should return selected date with set time when selected date is (today - 1 days) and start/end date earlier than (today -1 day)', + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + }); + }); + + describe('Test edge cases', () => { + [ + { + now: new Date(2024, 1, 29), + selectedDate: new Date(2024, 0, 31), + expected: new Date(2024, 1, 29), + }, + { + now: new Date(2024, 2, 10), + selectedDate: new Date(2024, 0, 30), + expected: new Date(2024, 1, 29), + }, + { + now: new Date(2024, 2, 1), + selectedDate: new Date(2024, 0, 29), + expected: new Date(2024, 1, 29), + }, + { + now: new Date(2024, 2, 10), + selectedDate: new Date(2024, 2, 29), + expected: new Date(2024, 1, 29), + }, + { + now: new Date(2024, 2, 10), + selectedDate: new Date(2024, 2, 30), + expected: new Date(2024, 1, 29), + }, + { + now: new Date(2024, 2, 10), + selectedDate: new Date(2024, 2, 31), + expected: new Date(2024, 1, 29), + }, + { + now: new Date(2024, 1, 29), + selectedDate: new Date(2024, 2, 31), + expected: new Date(2024, 1, 29), + }, + { + now: new Date(2024, 4, 10), + selectedDate: new Date(2024, 1, 29), + expected: new Date(2024, 3, 29), + }, + { + now: new Date(2024, 4, 10), + selectedDate: new Date(2024, 2, 31), + expected: new Date(2024, 3, 30), + }, + { + now: new Date(2024, 2, 10), + selectedDate: new Date(2023, 1, 28), + expected: new Date(2024, 1, 28), + }, + { + now: new Date(2023, 2, 10), + selectedDate: new Date(2024, 1, 29), + expected: new Date(2023, 1, 28), + }, + ].forEach(({ now, selectedDate, expected }) => { + it(`Should return ${expected.toDateString()} when now is ${now.toDateString()} and selected date is ${selectedDate.toDateString()}`, () => { + const calculator = getScheduledDateCalculator(now); + + const event = getMonthlyEvent(); + event.availability.startDate = subMonths(now, 6); + event.availability.endDate = addMonths(startOfDay(now), 6); + event.selectedDate = selectedDate; + + const resultDate = calculator.calculate(event, false); + + const expectedDate = new Date(expected); + + if (event.availability.timeFrom === null) { + throw new Error( + `[timeFrom is null] Should return ${expected.toDateString()} when now is ${now.toDateString()} and selected date is ${selectedDate.toDateString()}`, + ); + } + + expectedDate.setHours(event.availability.timeFrom.hours); + expectedDate.setMinutes(event.availability.timeFrom.minutes); + + expect(resultDate).toEqual(expectedDate); + }); + }); + }); +}); diff --git a/src/entities/event/model/operations/ScheduledDateCalculator.ts b/src/entities/event/model/operations/ScheduledDateCalculator.ts index b2151f3b4..a2f514a57 100644 --- a/src/entities/event/model/operations/ScheduledDateCalculator.ts +++ b/src/entities/event/model/operations/ScheduledDateCalculator.ts @@ -1,12 +1,4 @@ -import { - addDays, - differenceInMonths, - isEqual, - startOfDay, - subDays, - subMinutes, - subMonths, -} from 'date-fns'; +import { addDays, addMonths, isEqual, startOfDay, subDays, subMinutes, subMonths } from 'date-fns'; import { Parse, Day } from 'dayspan'; import { @@ -20,36 +12,49 @@ type EventParseInput = Parameters[0]; const cache = new Map(); -class ScheduledDateCalculator { - constructor() {} +export class ScheduledDateCalculator { private setTime(target: Date, availability: EventAvailability) { if (availability.timeFrom) { target.setHours(availability.timeFrom.hours); target.setMinutes(availability.timeFrom.minutes); } } - private getNow() { + + public getNow(): Date { return new Date(); } + private calculateForMonthly(selectedDate: Date, availability: EventAvailability): Date | null { const today = startOfDay(this.getNow()); + let date = new Date(selectedDate); - const diff = differenceInMonths(date, today); - const check = subMonths(date, diff); - if (check > today) { - date = subMonths(date, diff + 1); - } else { - date = check; + + if (selectedDate > today) { + let months = 0; + + while (date > today) { + months++; + date = subMonths(selectedDate, months); + } } - const aMonthAgo = subMonths(today, 1); - const isBeyondOfDateBorders = - date < aMonthAgo || (!!availability.endDate && date > availability.endDate); - if (isBeyondOfDateBorders) { - return null; + + if (selectedDate < today) { + let months = 0; + + while (date < today) { + months++; + date = addMonths(selectedDate, months); + } + if (date > today) { + date = subMonths(date, 1); + } } + this.setTime(date, availability); + return date; } + private calculateForSpecificDay(specificDay: Date, availability: EventAvailability): Date | null { const yesterday = subDays(startOfDay(this.getNow()), 1); @@ -64,6 +69,7 @@ class ScheduledDateCalculator { this.setTime(result, availability); return result; } + private calculateScheduledAt(event: ScheduleEvent): Date | null { const { availability, selectedDate } = event; const now = this.getNow(); @@ -77,40 +83,53 @@ class ScheduledDateCalculator { return this.calculateForSpecificDay(startOfDay(now), availability); } - if (scheduled && availability.periodicityType === PeriodicityType.Once) { - return this.calculateForSpecificDay(selectedDate!, availability); + if (scheduled && availability.periodicityType === PeriodicityType.Once && selectedDate) { + return this.calculateForSpecificDay(selectedDate, availability); } - if (availability.periodicityType === PeriodicityType.Monthly) { - return this.calculateForMonthly(selectedDate!, availability); + if (availability.periodicityType === PeriodicityType.Monthly && selectedDate) { + return this.calculateForMonthly(selectedDate, availability); } const parseInput: EventParseInput = {}; - if (availability.periodicityType === PeriodicityType.Weekly) { - const dayOfWeek = selectedDate!.getDay(); + if (availability.periodicityType === PeriodicityType.Weekly && selectedDate) { + const dayOfWeek = selectedDate.getDay(); parseInput.dayOfWeek = [dayOfWeek]; } else if (availability.periodicityType === PeriodicityType.Weekdays) { parseInput.dayOfWeek = [1, 2, 3, 4, 5]; } + if (availability.startDate) { parseInput.start = availability.startDate.getTime(); } + if (availability.endDate) { let endOfDay = addDays(availability.endDate, 1); endOfDay = subMinutes(endOfDay, 1); parseInput.end = endOfDay.getTime(); } + const parsedSchedule = Parse.schedule(parseInput); + const fromDate = Day.fromDate(now); - const futureSchedule = parsedSchedule.forecast(fromDate!, true, 1, 0, true); + + if (fromDate === null) { + throw new Error('[ScheduledDateCalculator]: fromDate is null'); + } + + const futureSchedule = parsedSchedule.forecast(fromDate, true, 1, 0, true); + const calculated = futureSchedule.first(); + if (!calculated) { return null; } + const result = calculated[0].start.date; this.setTime(result, availability); return result; } + public calculate(event: ScheduleEvent, useCache = true): Date | null { if (!useCache) { return this.calculateScheduledAt(event);