diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/recurrence.DST.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/recurrence.DST.test.ts new file mode 100644 index 000000000000..a63657d82be1 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/recurrence.DST.test.ts @@ -0,0 +1,123 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import { createScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; +import type { AppointmentModel } from './__mock__/model/appointment'; + +const ChicagoDST = [new Date('2025-03-08T00:00:00.000Z'), new Date('2025-11-01T00:00:00.000Z')]; // +1, -1 +const SydneyDST = [new Date('2025-04-07T00:00:00.000Z'), new Date('2025-10-04T00:00:00.000Z')]; // -1, +1 +const BelgradeDST = [new Date('2025-03-29T00:00:00.000Z'), new Date('2025-10-25T00:00:00.000Z')]; // +1, -1 +const dailyAppointment = { + startDate: new Date('2025-01-07T13:00:00.000Z'), + endDate: new Date('2025-01-07T14:00:00.000Z'), + startDateTimeZone: 'America/Chicago', + endDateTimeZone: 'America/Chicago', + recurrenceRule: 'FREQ=DAILY', +}; +const views = [{ type: 'week', intervalCount: 2 }]; + +const getDisplayDates = (appointments: AppointmentModel[]): string[] => appointments + .map((appointment) => appointment.getDisplayDate()); +const reduceDates = (texts: string[]): string[] => texts + .reduce((result, time) => { + if (result.at(-1) !== time) { + result.push(time); + } + + return result; + }, []); + +/* + * NOTE: + * display date = source date - source time zone offset + target time zone offset + * + * Chicago: UTC-6h, UTC-5h, UTC-6h + * Sydney: UTC+11h, UTC+10h, UTC+11h + * Belgrade: UTC+1h, UTC+2h, UTC+1h + * + * Chicago to Chicago: should keep the same display date + * Chicago to Sydney: +17h, +16h, +15h, +16h, +17h + * Chicago to Belgrade: +7h, +6h, +7h, +6h, +7h + */ +describe('Recurrence appointments', () => { + it('should change dates according to DST in target (Chicago) and appointment timezones (T1305659)', async () => { + setupSchedulerTestEnvironment(); + const { POM, scheduler } = await createScheduler({ + timeZone: 'America/Chicago', + dataSource: [dailyAppointment], + views, + currentView: 'week', + currentDate: ChicagoDST[0], + }); + + const getDates = () => getDisplayDates(POM.getAppointments()); + + const dates = getDates(); + scheduler.option('currentDate', ChicagoDST[1]); + dates.push(...getDates()); + + expect(reduceDates(dates)).toEqual([ + '7:00 AM - 8:00 AM', + ]); + }); + + it('should change dates according to DST in target (Sydney) and appointment timezones (T1305659)', async () => { + setupSchedulerTestEnvironment(); + const { POM, scheduler } = await createScheduler({ + timeZone: 'Australia/Sydney', + dataSource: [dailyAppointment], + views, + currentView: 'week', + currentDate: ChicagoDST[0], + }); + + const getDates = () => getDisplayDates(POM.getAppointments()); + + const dates = getDates(); + scheduler.option('currentDate', SydneyDST[0]); + dates.push(...getDates()); + scheduler.option('currentDate', SydneyDST[1]); + dates.push(...getDates()); + scheduler.option('currentDate', ChicagoDST[1]); + dates.push(...getDates()); + + expect(reduceDates(dates)).toEqual([ + '12:00 AM - 1:00 AM', + '11:00 PM - 12:00 AM', + '10:00 PM - 11:00 PM', + '11:00 PM - 12:00 AM', + '12:00 AM - 1:00 AM', + ]); + }); + + it('should change dates according to DST in target (Belgrade) and appointment timezones (T1305659)', async () => { + setupSchedulerTestEnvironment(); + const { POM, scheduler } = await createScheduler({ + timeZone: 'Europe/Belgrade', + dataSource: [dailyAppointment], + views, + currentView: 'week', + currentDate: ChicagoDST[0], + }); + + const getDates = () => getDisplayDates(POM.getAppointments()); + + const dates = getDates(); + scheduler.option('currentDate', BelgradeDST[0]); + dates.push(...getDates()); + scheduler.option('currentDate', BelgradeDST[1]); + dates.push(...getDates()); + scheduler.option('currentDate', ChicagoDST[1]); + dates.push(...getDates()); + + expect(reduceDates(dates)).toEqual([ + '2:00 PM - 3:00 PM', + '1:00 PM - 2:00 PM', + '2:00 PM - 3:00 PM', + '1:00 PM - 2:00 PM', + '2:00 PM - 3:00 PM', + ]); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_recurrence/get_appointment_recurrence_occurrences.test.ts b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_recurrence/get_appointment_recurrence_occurrences.test.ts index 525c567fd505..a2bb98cd56c9 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_recurrence/get_appointment_recurrence_occurrences.test.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_recurrence/get_appointment_recurrence_occurrences.test.ts @@ -90,205 +90,255 @@ describe('getAppointmentRecurrenceOccurrences', () => { }); }); - it('should return appointment occurrences for appointment starts before view interval', () => { - const appointment: any = { - source: { - startDate: Date.UTC(2000, 0, 9, 10), - endDate: Date.UTC(2000, 0, 9, 11), - }, - recurrenceRule: 'FREQ=DAILY', - hasRecurrenceRule: true, - }; - expect(getAppointmentRecurrenceOccurrences( - appointment, - options, - )).toEqual([ - { - ...appointment, + describe('with recurrence rule', () => { + it('should return the same source in any timezone if appointment timezone set', () => { + const appointment: any = { source: { - startDate: Date.UTC(2000, 0, 10, 10), - endDate: Date.UTC(2000, 0, 10, 11), + startDate: Date.UTC(2025, 0, 7, 1), + endDate: Date.UTC(2025, 0, 7, 2), }, - startDateUTC: Date.UTC(2000, 0, 10, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 10, 11) - HOUR_MS * 8, - }, - { - ...appointment, + startDateTimeZone: 'America/Chicago', + endDateTimeZone: 'America/Chicago', + recurrenceRule: 'FREQ=DAILY', + hasRecurrenceRule: true, + }; + const getSources = (date: number, timeZone: string) => { + const dateCopy = new Date(date); + return getAppointmentRecurrenceOccurrences( + appointment, + { + interval: { + min: dateCopy.setDate(dateCopy.getDate() - 2), + max: dateCopy.setDate(dateCopy.getDate() + 2), + }, + timeZone, + }, + ).map((item) => ({ + startDate: new Date(item.source.startDate).toUTCString(), + endDate: new Date(item.source.endDate).toUTCString(), + })); + }; + + const sourcesChicago = [ + ...getSources(Date.UTC(2025, 1), 'America/Chicago'), + ...getSources(Date.UTC(2025, 6), 'America/Chicago'), + ...getSources(Date.UTC(2025, 11), 'America/Chicago'), + ]; + const sourcesSydney = [ + ...getSources(Date.UTC(2025, 1) + 24 * HOUR_MS, 'Australia/Sydney'), + ...getSources(Date.UTC(2025, 6) + 24 * HOUR_MS, 'Australia/Sydney'), + ...getSources(Date.UTC(2025, 11) + 24 * HOUR_MS, 'Australia/Sydney'), + ]; + const sourcesBelgrade = [ + ...getSources(Date.UTC(2025, 1) + 24 * HOUR_MS, 'Europe/Belgrade'), + ...getSources(Date.UTC(2025, 6) + 24 * HOUR_MS, 'Europe/Belgrade'), + ...getSources(Date.UTC(2025, 11) + 24 * HOUR_MS, 'Europe/Belgrade'), + ]; + + expect(sourcesChicago).toEqual(sourcesSydney); + expect(sourcesChicago).toEqual(sourcesBelgrade); + }); + + it('should return appointment occurrences for appointment starts before view interval', () => { + const appointment: any = { source: { - startDate: Date.UTC(2000, 0, 11, 10), - endDate: Date.UTC(2000, 0, 11, 11), + startDate: Date.UTC(2000, 0, 9, 10), + endDate: Date.UTC(2000, 0, 9, 11), }, - startDateUTC: Date.UTC(2000, 0, 11, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 11, 11) - HOUR_MS * 8, - }, - { - ...appointment, - source: { - startDate: Date.UTC(2000, 0, 12, 10), - endDate: Date.UTC(2000, 0, 12, 11), + recurrenceRule: 'FREQ=DAILY', + hasRecurrenceRule: true, + }; + expect(getAppointmentRecurrenceOccurrences( + appointment, + options, + )).toEqual([ + { + ...appointment, + source: { + startDate: Date.UTC(2000, 0, 10, 10), + endDate: Date.UTC(2000, 0, 10, 11), + }, + startDateUTC: Date.UTC(2000, 0, 10, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 10, 11) - HOUR_MS * 8, }, - startDateUTC: Date.UTC(2000, 0, 12, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 12, 11) - HOUR_MS * 8, - }, - { - ...appointment, + { + ...appointment, + source: { + startDate: Date.UTC(2000, 0, 11, 10), + endDate: Date.UTC(2000, 0, 11, 11), + }, + startDateUTC: Date.UTC(2000, 0, 11, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 11, 11) - HOUR_MS * 8, + }, + { + ...appointment, + source: { + startDate: Date.UTC(2000, 0, 12, 10), + endDate: Date.UTC(2000, 0, 12, 11), + }, + startDateUTC: Date.UTC(2000, 0, 12, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 12, 11) - HOUR_MS * 8, + }, + { + ...appointment, + source: { + startDate: Date.UTC(2000, 0, 13, 10), + endDate: Date.UTC(2000, 0, 13, 11), + }, + startDateUTC: Date.UTC(2000, 0, 13, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 13, 11) - HOUR_MS * 8, + }, + { + ...appointment, + source: { + startDate: Date.UTC(2000, 0, 14, 10), + endDate: Date.UTC(2000, 0, 14, 11), + }, + startDateUTC: Date.UTC(2000, 0, 14, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 14, 11) - HOUR_MS * 8, + }, + ]); + }); + + it('should return appointment occurrences for appointment starts inside view interval', () => { + const appointment: any = { source: { startDate: Date.UTC(2000, 0, 13, 10), endDate: Date.UTC(2000, 0, 13, 11), }, - startDateUTC: Date.UTC(2000, 0, 13, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 13, 11) - HOUR_MS * 8, - }, - { - ...appointment, + recurrenceRule: 'FREQ=DAILY', + hasRecurrenceRule: true, + }; + expect(getAppointmentRecurrenceOccurrences( + appointment, + options, + )).toEqual([ + { + ...appointment, + startDateUTC: Date.UTC(2000, 0, 13, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 13, 11) - HOUR_MS * 8, + }, + { + ...appointment, + source: { + startDate: Date.UTC(2000, 0, 14, 10), + endDate: Date.UTC(2000, 0, 14, 11), + }, + startDateUTC: Date.UTC(2000, 0, 14, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 14, 11) - HOUR_MS * 8, + }, + ]); + }); + + it('should return appointment occurrences for appointment starts after view interval', () => { + const appointment: any = { source: { - startDate: Date.UTC(2000, 0, 14, 10), - endDate: Date.UTC(2000, 0, 14, 11), + startDate: Date.UTC(2000, 0, 20, 10), + endDate: Date.UTC(2000, 0, 13, 11), }, - startDateUTC: Date.UTC(2000, 0, 14, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 14, 11) - HOUR_MS * 8, - }, - ]); - }); + recurrenceRule: 'FREQ=DAILY', + hasRecurrenceRule: true, + }; + expect(getAppointmentRecurrenceOccurrences( + appointment, + options, + )).toEqual([]); + }); - it('should return appointment occurrences for appointment starts inside view interval', () => { - const appointment: any = { - source: { - startDate: Date.UTC(2000, 0, 13, 10), - endDate: Date.UTC(2000, 0, 13, 11), - }, - recurrenceRule: 'FREQ=DAILY', - hasRecurrenceRule: true, - }; - expect(getAppointmentRecurrenceOccurrences( - appointment, - options, - )).toEqual([ - { - ...appointment, - startDateUTC: Date.UTC(2000, 0, 13, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 13, 11) - HOUR_MS * 8, - }, - { + it.each([ + { title: 'appointment', delta: 0 }, + { title: 'appointment occurrence', delta: -20 }, + ])('should return $title is hagging view interval', ({ delta }) => { + const appointment: any = { + source: { + startDate: Date.UTC(2000, 0, 9 + delta), + endDate: Date.UTC(2000, 0, 16 + delta), + }, + recurrenceRule: 'FREQ=DAILY;INTERVAL=20', + hasRecurrenceRule: true, + }; + expect(getAppointmentRecurrenceOccurrences( + appointment, + options, + )).toEqual([{ ...appointment, source: { - startDate: Date.UTC(2000, 0, 14, 10), - endDate: Date.UTC(2000, 0, 14, 11), + startDate: Date.UTC(2000, 0, 9), + endDate: Date.UTC(2000, 0, 16), }, - startDateUTC: Date.UTC(2000, 0, 14, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 14, 11) - HOUR_MS * 8, - }, - ]); - }); - - it('should return appointment occurrences for appointment starts after view interval', () => { - const appointment: any = { - source: { - startDate: Date.UTC(2000, 0, 20, 10), - endDate: Date.UTC(2000, 0, 13, 11), - }, - recurrenceRule: 'FREQ=DAILY', - hasRecurrenceRule: true, - }; - expect(getAppointmentRecurrenceOccurrences( - appointment, - options, - )).toEqual([]); - }); - - it.each([ - { title: 'appointment', delta: 0 }, - { title: 'appointment occurrence', delta: -20 }, - ])('should return $title is hagging view interval', ({ delta }) => { - const appointment: any = { - source: { - startDate: Date.UTC(2000, 0, 9 + delta), - endDate: Date.UTC(2000, 0, 16 + delta), - }, - recurrenceRule: 'FREQ=DAILY;INTERVAL=20', - hasRecurrenceRule: true, - }; - expect(getAppointmentRecurrenceOccurrences( - appointment, - options, - )).toEqual([{ - ...appointment, - source: { - startDate: Date.UTC(2000, 0, 9), - endDate: Date.UTC(2000, 0, 16), - }, - startDateUTC: Date.UTC(2000, 0, 9) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 16) - HOUR_MS * 8, - }]); - }); - - it.each([ - { title: 'appointment', delta: 0 }, - { title: 'appointment occurrence', delta: -10 }, - ])('should return $title starts before view interval', ({ delta }) => { - const appointment: any = { - source: { - startDate: Date.UTC(2000, 0, 9 + delta, 20), - endDate: Date.UTC(2000, 0, 10 + delta, 10), - }, - recurrenceRule: 'FREQ=DAILY;INTERVAL=10', - hasRecurrenceRule: true, - }; - expect(getAppointmentRecurrenceOccurrences( - appointment, - options, - )).toEqual([{ - ...appointment, - source: { - startDate: Date.UTC(2000, 0, 9, 20), - endDate: Date.UTC(2000, 0, 10, 10), - }, - startDateUTC: Date.UTC(2000, 0, 9, 20) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 10, 10) - HOUR_MS * 8, - }]); - }); + startDateUTC: Date.UTC(2000, 0, 9) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 16) - HOUR_MS * 8, + }]); + }); - it('should return appointment occurrences for appointment with exceptions', () => { - const exception1 = getAsciiStringByDate( - new Date(Date.UTC(2000, 0, 11, 10)), - ); - const exception2 = getAsciiStringByDate( - new Date(Date.UTC(2000, 0, 12, 10)), - ); - const exception3 = getAsciiStringByDate( - new Date(Date.UTC(2000, 0, 13, 10)), - ); - const appointment: any = { - source: { - startDate: Date.UTC(2000, 0, 9, 10), - endDate: Date.UTC(2000, 0, 9, 11), - }, - recurrenceException: `${exception1},${exception2},${exception3}`, - recurrenceRule: 'FREQ=DAILY', - hasRecurrenceRule: true, - }; - expect(getAppointmentRecurrenceOccurrences( - appointment, - options, - )).toEqual([ - { - ...appointment, + it.each([ + { title: 'appointment', delta: 0 }, + { title: 'appointment occurrence', delta: -10 }, + ])('should return $title starts before view interval', ({ delta }) => { + const appointment: any = { source: { - startDate: Date.UTC(2000, 0, 10, 10), - endDate: Date.UTC(2000, 0, 10, 11), + startDate: Date.UTC(2000, 0, 9 + delta, 20), + endDate: Date.UTC(2000, 0, 10 + delta, 10), }, - startDateUTC: Date.UTC(2000, 0, 10, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 10, 11) - HOUR_MS * 8, - }, - { + recurrenceRule: 'FREQ=DAILY;INTERVAL=10', + hasRecurrenceRule: true, + }; + expect(getAppointmentRecurrenceOccurrences( + appointment, + options, + )).toEqual([{ ...appointment, source: { - startDate: Date.UTC(2000, 0, 14, 10), - endDate: Date.UTC(2000, 0, 14, 11), + startDate: Date.UTC(2000, 0, 9, 20), + endDate: Date.UTC(2000, 0, 10, 10), + }, + startDateUTC: Date.UTC(2000, 0, 9, 20) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 10, 10) - HOUR_MS * 8, + }]); + }); + + it('should return appointment occurrences for appointment with exceptions', () => { + const exception1 = getAsciiStringByDate( + new Date(Date.UTC(2000, 0, 11, 10)), + ); + const exception2 = getAsciiStringByDate( + new Date(Date.UTC(2000, 0, 12, 10)), + ); + const exception3 = getAsciiStringByDate( + new Date(Date.UTC(2000, 0, 13, 10)), + ); + const appointment: any = { + source: { + startDate: Date.UTC(2000, 0, 9, 10), + endDate: Date.UTC(2000, 0, 9, 11), + }, + recurrenceException: `${exception1},${exception2},${exception3}`, + recurrenceRule: 'FREQ=DAILY', + hasRecurrenceRule: true, + }; + expect(getAppointmentRecurrenceOccurrences( + appointment, + options, + )).toEqual([ + { + ...appointment, + source: { + startDate: Date.UTC(2000, 0, 10, 10), + endDate: Date.UTC(2000, 0, 10, 11), + }, + startDateUTC: Date.UTC(2000, 0, 10, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 10, 11) - HOUR_MS * 8, }, - startDateUTC: Date.UTC(2000, 0, 14, 10) - HOUR_MS * 8, - endDateUTC: Date.UTC(2000, 0, 14, 11) - HOUR_MS * 8, - }, - ]); + { + ...appointment, + source: { + startDate: Date.UTC(2000, 0, 14, 10), + endDate: Date.UTC(2000, 0, 14, 11), + }, + startDateUTC: Date.UTC(2000, 0, 14, 10) - HOUR_MS * 8, + endDateUTC: Date.UTC(2000, 0, 14, 11) - HOUR_MS * 8, + }, + ]); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_recurrence/get_appointment_recurrence_occurrences.ts b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_recurrence/get_appointment_recurrence_occurrences.ts index cda62fa7dba0..b69fdd86720a 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_recurrence/get_appointment_recurrence_occurrences.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/filtration/utils/split_by_recurrence/get_appointment_recurrence_occurrences.ts @@ -44,20 +44,6 @@ const getUnreachableShift = ( } }; -const getDSTChanges = ( - targetTimeZoneChange: number, - appointmentTimeZoneChange: number, -): number => { - if (targetTimeZoneChange < 0 && appointmentTimeZoneChange < 0) { - return Math.min(targetTimeZoneChange, appointmentTimeZoneChange); - } - if (targetTimeZoneChange > 0 && appointmentTimeZoneChange > 0) { - return Math.max(targetTimeZoneChange, appointmentTimeZoneChange); - } - - return targetTimeZoneChange + appointmentTimeZoneChange; -}; - export const getAppointmentRecurrenceOccurrences = ( appointment: T, { @@ -109,17 +95,17 @@ export const getAppointmentRecurrenceOccurrences =