Skip to content

Commit 8055ffb

Browse files
authored
Scheduler(T1225416): fix incorrect exclude from recurrence handling (DevExpress#28960)
1 parent a1dbdc3 commit 8055ffb

File tree

4 files changed

+118
-33
lines changed

4 files changed

+118
-33
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import Scheduler from 'devextreme-testcafe-models/scheduler';
2+
import {
3+
getTimezoneTest,
4+
MACHINE_TIMEZONES,
5+
MachineTimezonesType,
6+
} from '../../../../helpers/machineTimezones';
7+
import url from '../../../../helpers/getPageUrl';
8+
import { generateOptionMatrix } from '../../../../helpers/generateOptionMatrix';
9+
import { createWidget } from '../../../../helpers/createWidget';
10+
11+
fixture`Scheduler exclude from recurrence`
12+
.page(url(__dirname, '../../../container.html'));
13+
14+
const SCHEDULER_SELECTOR = '#container';
15+
const MS_IN_MINUTE = 60000;
16+
const MS_IN_HOUR = MS_IN_MINUTE * 60;
17+
const APPOINTMENT_TEXT = 'TEST_APPT';
18+
19+
const getAppointments = (
20+
startDate: Date,
21+
currentView: string,
22+
) => [
23+
{
24+
startDate,
25+
endDate: new Date(startDate.getTime() + MS_IN_HOUR),
26+
text: APPOINTMENT_TEXT,
27+
recurrenceRule: currentView === 'week' ? 'FREQ=DAILY' : 'FREQ=WEEKLY;BYDAY=FR',
28+
},
29+
];
30+
31+
const getFirstDayOfWeek = (currentView: string) => (currentView === 'week' ? 4 : 0);
32+
const getAppointmentsCount = (currentView: string) => (currentView === 'week' ? 7 : 6);
33+
34+
generateOptionMatrix({
35+
timeZone: [undefined, 'America/New_York'],
36+
currentView: ['week', 'month'],
37+
location: [
38+
[MACHINE_TIMEZONES.EuropeBerlin, 'summer', '2024-03-31', new Date('2024-01-01T12:00:00Z')],
39+
[MACHINE_TIMEZONES.EuropeBerlin, 'winter', '2024-10-27', new Date('2024-01-01T12:00:00Z')],
40+
[MACHINE_TIMEZONES.AmericaLosAngeles, 'summer', '2024-03-10', new Date('2024-01-01T12:00:00Z')],
41+
[MACHINE_TIMEZONES.AmericaLosAngeles, 'winter', '2024-11-03', new Date('2024-01-01T12:00:00Z')],
42+
] as [MachineTimezonesType, string, string, Date][],
43+
}).forEach(({
44+
timeZone,
45+
currentView,
46+
location: [machineTimezone, caseName, currentDate, startDate],
47+
}) => {
48+
const dataSource = getAppointments(startDate, currentView);
49+
const firstDayOfWeek = getFirstDayOfWeek(currentView);
50+
const appointmentsCount = getAppointmentsCount(currentView);
51+
52+
getTimezoneTest([machineTimezone])(
53+
`Should correctly exclude appointment from recurrence (week, ${timeZone}, ${machineTimezone}, ${caseName})`,
54+
async (t) => {
55+
const scheduler = new Scheduler(SCHEDULER_SELECTOR);
56+
57+
await t.expect(scheduler.getAppointmentCount()).eql(appointmentsCount);
58+
59+
for (let idx = 0; idx < appointmentsCount; idx += 1) {
60+
await t.click(scheduler.getAppointment(APPOINTMENT_TEXT, 0).element)
61+
.click(scheduler.appointmentTooltip.deleteButton);
62+
63+
await t.expect(scheduler.getAppointmentCount()).eql(appointmentsCount - (idx + 1));
64+
}
65+
66+
await t.expect(scheduler.getAppointmentCount()).eql(0);
67+
},
68+
).before(async () => {
69+
await createWidget('dxScheduler', {
70+
timeZone,
71+
dataSource,
72+
currentDate,
73+
currentView,
74+
firstDayOfWeek,
75+
recurrenceEditMode: 'occurrence',
76+
});
77+
});
78+
});

packages/devextreme/js/__internal/scheduler/appointments/m_settings_generator.ts

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ export class DateGeneratorBaseStrategy {
167167

168168
return {
169169
...item,
170+
// TODO: Check usages & delete this field.
170171
exceptionDate: new Date(item.startDate),
171172
};
172173
});
@@ -188,22 +189,20 @@ export class DateGeneratorBaseStrategy {
188189
return !timeZoneUtils.isEqualLocalTimeZone(this.timeZone, appointment.startDate);
189190
}
190191

191-
_getProcessedNotNativeDateIfCrossDST(date, offset) {
192-
if (offset < 0) { // summer time
193-
const newDate = new Date(date);
194-
195-
const newDateMinusOneHour = new Date(newDate);
196-
newDateMinusOneHour.setHours(newDateMinusOneHour.getHours() - 1);
192+
_getDateOffsetDST(date) {
193+
const dateMinusHour = new Date(date);
194+
dateMinusHour.setHours(dateMinusHour.getHours() - 1);
197195

198-
const newDateOffset = this.timeZoneCalculator.getOffsets(newDate).common;
199-
const newDateMinusOneHourOffset = this.timeZoneCalculator.getOffsets(newDateMinusOneHour).common;
196+
const dateCommonOffset = this.timeZoneCalculator.getOffsets(date).common;
197+
const dateMinusHourCommonOffset = this.timeZoneCalculator.getOffsets(dateMinusHour).common;
200198

201-
if (newDateOffset !== newDateMinusOneHourOffset) {
202-
return 0;
203-
}
204-
}
199+
return dateMinusHourCommonOffset - dateCommonOffset;
200+
}
205201

206-
return offset;
202+
_getProcessedNotNativeDateIfCrossDST(date, offset) {
203+
return offset < 0 && this._getDateOffsetDST(date) !== 0
204+
? 0
205+
: offset;
207206
}
208207

209208
_getCommonOffset(date) {
@@ -236,6 +235,7 @@ export class DateGeneratorBaseStrategy {
236235
...item,
237236
startDate: newStartDate,
238237
endDate: newEndDate,
238+
// TODO: Check usages & delete this field.
239239
exceptionDate: new Date(newStartDate),
240240
};
241241
});
@@ -386,18 +386,26 @@ export class DateGeneratorBaseStrategy {
386386
true,
387387
),
388388

389-
getPostProcessedException: (date) => {
390-
if (isEmptyObject(this.timeZone) || timeZoneUtils.isEqualLocalTimeZone(this.timeZone, date)) {
391-
return date;
392-
}
393-
394-
const appointmentOffset = this.timeZoneCalculator.getOffsets(originalAppointmentStartDate).common;
395-
const exceptionAppointmentOffset = this.timeZoneCalculator.getOffsets(date).common;
396-
397-
let diff = appointmentOffset - exceptionAppointmentOffset;
398-
diff = this._getProcessedNotNativeDateIfCrossDST(date, diff);
399-
400-
return new Date(date.getTime() - diff * dateUtils.dateToMilliseconds('hour'));
389+
getExceptionDateTimezoneOffsets: (date: Date): [number, number, number] => {
390+
const localMachineTimezoneOffset = -timeZoneUtils.getClientTimezoneOffset(date);
391+
392+
const appointmentTimezoneOffset: number = this.timeZoneCalculator.getOriginStartDateOffsetInMs(
393+
date,
394+
appointment.rawAppointment.startDateTimeZone,
395+
true,
396+
);
397+
398+
const offsetDST = this._getDateOffsetDST(date);
399+
// NOTE: Apply only winter -> summer DST extra offset
400+
const extraSummerTimeChangeOffset = offsetDST < 0
401+
? offsetDST * toMs('hour')
402+
: 0;
403+
404+
return [
405+
localMachineTimezoneOffset,
406+
appointmentTimezoneOffset,
407+
extraSummerTimeChangeOffset,
408+
];
401409
},
402410
};
403411
}

packages/devextreme/js/__internal/scheduler/m_recurrence.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,16 +296,15 @@ class RecurrenceProcessor {
296296
.map((rule) => this.getDateByAsciiString(rule));
297297

298298
exceptionDates.forEach((date) => {
299-
if (options.getPostProcessedException) {
300-
date = options.getPostProcessedException(date);
301-
}
302-
303-
const utcDate = timeZoneUtils.setOffsetsToDate(
299+
const rruleTimezoneOffsets = typeof options.getExceptionDateTimezoneOffsets === 'function'
300+
? options.getExceptionDateTimezoneOffsets(date)
301+
: [-timeZoneUtils.getClientTimezoneOffset(date), options.appointmentTimezoneOffset];
302+
const exceptionDateInPseudoUtc = timeZoneUtils.setOffsetsToDate(
304303
date,
305-
[-timeZoneUtils.getClientTimezoneOffset(date), options.appointmentTimezoneOffset],
304+
rruleTimezoneOffsets,
306305
);
307306

308-
this.rRuleSet!.exdate(utcDate);
307+
this.rRuleSet!.exdate(exceptionDateInPseudoUtc);
309308
});
310309
}
311310
}

packages/devextreme/js/__internal/scheduler/m_subscribes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const subscribes = {
101101
}
102102

103103
if ((newCellIndex !== oldCellIndex) || isDragAndDropBetweenComponents || movedBetweenAllDayAndSimple) {
104-
this._checkRecurringAppointment(rawAppointment, targetedRawAppointment, info.sourceAppointment.exceptionDate, () => {
104+
this._checkRecurringAppointment(rawAppointment, targetedRawAppointment, info.sourceAppointment.startDate, () => {
105105
this._updateAppointment(rawAppointment, targetedRawAppointment, function () {
106106
this._appointments.moveAppointmentBack(event);
107107
}, event);

0 commit comments

Comments
 (0)