Skip to content

Commit 9b5be94

Browse files
committed
Scheduler: fix recurrence appointment display date according DST (T1305659)
1 parent 26f9cf8 commit 9b5be94

4 files changed

Lines changed: 196 additions & 26 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* @timezone Europe/Belgrade
3+
*/
4+
5+
import {
6+
describe, expect, it,
7+
} from '@jest/globals';
8+
9+
import { createScheduler } from './__mock__/create_scheduler';
10+
import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler';
11+
import type { AppointmentModel } from './__mock__/model/appointment';
12+
13+
const ChicagoDST = [new Date('2025-03-08T00:00:00.000Z'), new Date('2025-11-01T00:00:00.000Z')]; // +1, -1
14+
const SydneyDST = [new Date('2025-04-07T00:00:00.000Z'), new Date('2025-10-04T00:00:00.000Z')]; // -1, +1
15+
const BelgradeDST = [new Date('2025-03-29T00:00:00.000Z'), new Date('2025-10-25T00:00:00.000Z')]; // +1, -1
16+
const dailyAppointment = {
17+
startDate: new Date('2025-01-07T13:00:00.000Z'),
18+
endDate: new Date('2025-01-07T14:00:00.000Z'),
19+
startDateTimeZone: 'America/Chicago',
20+
endDateTimeZone: 'America/Chicago',
21+
recurrenceRule: 'FREQ=DAILY',
22+
};
23+
const views = [{ type: 'week', intervalCount: 2 }];
24+
25+
const getDisplayDates = (appointments: AppointmentModel[]): string[] => appointments
26+
.map((appointment) => appointment.getDisplayDate());
27+
const reduceDates = (texts: string[]): string[] => texts
28+
.reduce<string[]>((result, time) => {
29+
if (result.at(-1) !== time) {
30+
result.push(time);
31+
}
32+
33+
return result;
34+
}, []);
35+
36+
/*
37+
* NOTE:
38+
* display date = source date - source time zone offset + target time zone offset
39+
*
40+
* Chicago: UTC-6h, UTC-5h, UTC-6h
41+
* Sydney: UTC+11h, UTC+10h, UTC+11h
42+
* Belgrade: UTC+1h, UTC+2h, UTC+1h
43+
*
44+
* Chicago to Chicago: should keep the same display date
45+
* Chicago to Sydney: +17h, +16h, +15h, +16h, +17h
46+
* Chicago to Belgrade: +7h, +6h, +7h, +6h, +7h
47+
*/
48+
describe('Recurrence appointments', () => {
49+
it('should change dates according to DST in target (Chicago) and appointment timezones (T1305659)', async () => {
50+
setupSchedulerTestEnvironment();
51+
const { POM, scheduler } = await createScheduler({
52+
timeZone: 'America/Chicago',
53+
dataSource: [dailyAppointment],
54+
views,
55+
currentView: 'week',
56+
currentDate: ChicagoDST[0],
57+
firstDayOfWeek: 3,
58+
});
59+
60+
const getDates = () => getDisplayDates(POM.getAppointments());
61+
62+
const dates = getDates();
63+
scheduler.option('currentDate', ChicagoDST[1]);
64+
dates.push(...getDates());
65+
66+
expect(reduceDates(dates)).toEqual([
67+
'7:00 AM - 8:00 AM',
68+
]);
69+
});
70+
71+
it('should change dates according to DST in target (Sydney) and appointment timezones (T1305659)', async () => {
72+
setupSchedulerTestEnvironment();
73+
const { POM, scheduler } = await createScheduler({
74+
timeZone: 'Australia/Sydney',
75+
dataSource: [dailyAppointment],
76+
views,
77+
currentView: 'week',
78+
currentDate: ChicagoDST[0],
79+
firstDayOfWeek: 3,
80+
});
81+
82+
const getDates = () => getDisplayDates(POM.getAppointments());
83+
84+
const dates = getDates();
85+
scheduler.option('currentDate', SydneyDST[0]);
86+
dates.push(...getDates());
87+
scheduler.option('currentDate', SydneyDST[1]);
88+
dates.push(...getDates());
89+
scheduler.option('currentDate', ChicagoDST[1]);
90+
dates.push(...getDates());
91+
92+
expect(reduceDates(dates)).toEqual([
93+
'12:00 AM - 1:00 AM',
94+
'11:00 PM - 12:00 AM',
95+
'10:00 PM - 11:00 PM',
96+
'11:00 PM - 12:00 AM',
97+
'12:00 AM - 1:00 AM',
98+
]);
99+
});
100+
101+
it('should change dates according to DST in target (Belgrade) and appointment timezones (T1305659)', async () => {
102+
setupSchedulerTestEnvironment();
103+
const { POM, scheduler } = await createScheduler({
104+
timeZone: 'Europe/Belgrade',
105+
dataSource: [dailyAppointment],
106+
views,
107+
currentView: 'week',
108+
currentDate: ChicagoDST[0],
109+
firstDayOfWeek: 3,
110+
});
111+
112+
const getDates = () => getDisplayDates(POM.getAppointments());
113+
114+
const dates = getDates();
115+
scheduler.option('currentDate', BelgradeDST[0]);
116+
dates.push(...getDates());
117+
scheduler.option('currentDate', BelgradeDST[1]);
118+
dates.push(...getDates());
119+
scheduler.option('currentDate', ChicagoDST[1]);
120+
dates.push(...getDates());
121+
122+
expect(reduceDates(dates)).toEqual([
123+
'2:00 PM - 3:00 PM',
124+
'1:00 PM - 2:00 PM',
125+
'2:00 PM - 3:00 PM',
126+
'1:00 PM - 2:00 PM',
127+
'2:00 PM - 3:00 PM',
128+
]);
129+
});
130+
131+
it('should change dates according to DST in target (Local Belgrade) and appointment timezones (T1305659)', async () => {
132+
setupSchedulerTestEnvironment();
133+
const { POM, scheduler } = await createScheduler({
134+
dataSource: [dailyAppointment],
135+
views,
136+
currentView: 'week',
137+
currentDate: ChicagoDST[0],
138+
firstDayOfWeek: 3,
139+
});
140+
141+
const getDates = () => getDisplayDates(POM.getAppointments());
142+
143+
const dates = getDates();
144+
scheduler.option('currentDate', BelgradeDST[0]);
145+
dates.push(...getDates());
146+
scheduler.option('currentDate', BelgradeDST[1]);
147+
dates.push(...getDates());
148+
scheduler.option('currentDate', ChicagoDST[1]);
149+
dates.push(...getDates());
150+
151+
expect(reduceDates(dates)).toEqual([
152+
'2:00 PM - 3:00 PM',
153+
'1:00 PM - 2:00 PM',
154+
'2:00 PM - 3:00 PM',
155+
'1:00 PM - 2:00 PM',
156+
'2:00 PM - 3:00 PM',
157+
]);
158+
});
159+
});

packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/add_collector.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { isDefined } from '@js/core/utils/type';
2-
import type { TimeZoneCalculator } from '@ts/scheduler/r1/timezone_calculator';
32
import type { SafeAppointment } from '@ts/scheduler/types';
43

54
import { plainViewModel } from './plain_view_model';
@@ -74,7 +73,6 @@ const processVirtualAppointment = (
7473

7574
export const addCollector = (
7675
viewModel: AppointmentViewModelInternal[],
77-
timeZoneCalculator: TimeZoneCalculator,
7876
): AppointmentViewModelPlain[] => {
7977
const internalViewModelItems = plainViewModel(viewModel);
8078
const result: AppointmentViewModelPlain[] = [];
@@ -84,11 +82,7 @@ export const addCollector = (
8482
...item,
8583
info: {
8684
sourceAppointment: item.info.sourceAppointment,
87-
appointment: {
88-
...item.info.appointment,
89-
startDate: timeZoneCalculator.createDate(item.info.sourceAppointment.startDate, 'toGrid'),
90-
endDate: timeZoneCalculator.createDate(item.info.sourceAppointment.endDate, 'toGrid'),
91-
},
85+
appointment: item.info.appointment,
9286
},
9387
})).forEach((item) => {
9488
switch (true) {

packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/m_settings_generator.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import dateUtils from '@js/core/utils/date';
33
import { extend } from '@js/core/utils/extend';
44
import { isEmptyObject } from '@js/core/utils/type';
55
import { dateUtilsTs } from '@ts/core/utils/date';
6+
import { getAsciiStringByDate } from '@ts/scheduler/recurrence/base';
67

78
import { createFormattedDateText } from '../../appointments/m_text_utils';
89
import timeZoneUtils from '../../m_utils_time_zone';
@@ -79,15 +80,25 @@ export class DateGeneratorBaseStrategy {
7980

8081
const itemGroupIndices = this._getGroupIndices(this.rawAppointment);
8182

82-
let appointmentList = this._createAppointments(appointmentAdapter, itemGroupIndices);
83+
let sourceList = this._createAppointments(appointmentAdapter, itemGroupIndices);
84+
sourceList = this.excludeLocalDST(sourceList, appointmentAdapter);
85+
sourceList = this._getProcessedByAppointmentTimeZone(sourceList, appointmentAdapter); // T983264
8386

84-
appointmentList = this._getProcessedByAppointmentTimeZone(appointmentList, appointmentAdapter); // T983264
87+
if (appointmentAdapter.isRecurrent && appointmentAdapter.recurrenceException) {
88+
const exceptions = new Set(appointmentAdapter.recurrenceException?.split(','));
89+
sourceList = sourceList
90+
.filter((item) => !exceptions.has(getAsciiStringByDate(item.startDate)));
91+
}
8592

86-
if (this._canProcessNotNativeTimezoneDates(appointmentAdapter)) {
87-
appointmentList = this._getProcessedNotNativeTimezoneDates(appointmentList, appointmentAdapter);
93+
let appointmentList = sourceList;
94+
if (appointmentAdapter.isRecurrent && (
95+
!this.timeZone
96+
|| timeZoneUtils.isEqualLocalTimeZone(this.timeZone)
97+
)) {
98+
appointmentList = this._getProcessedNativeTimezoneDates(appointmentList, appointmentAdapter);
8899
}
89100

90-
let dateSettings = this._createGridAppointmentList(appointmentList, appointmentAdapter);
101+
let dateSettings = this._createGridAppointmentList(appointmentList, sourceList, appointmentAdapter);
91102

92103
const firstViewDates = this._getAppointmentsFirstViewDate(dateSettings);
93104

@@ -135,15 +146,15 @@ export class DateGeneratorBaseStrategy {
135146
endDate: this.timeZoneCalculator.getOffsets(a.endDate, appointment.endDateTimeZone),
136147
};
137148

138-
const startDateOffsetDiff = appointmentOffsets.startDate.appointment - sourceOffsets.startDate.appointment;
139-
const endDateOffsetDiff = appointmentOffsets.endDate.appointment - sourceOffsets.endDate.appointment;
149+
const startDateOffsetDiff = appointment.startDateTimeZone
150+
? appointmentOffsets.startDate.appointment - sourceOffsets.startDate.appointment
151+
: 0;
152+
const endDateOffsetDiff = appointment.endDateTimeZone
153+
? appointmentOffsets.endDate.appointment - sourceOffsets.endDate.appointment
154+
: 0;
140155

141-
if (sourceOffsets.startDate.appointment !== sourceOffsets.startDate.common) {
142-
a.startDate = new Date(a.startDate.getTime() + startDateOffsetDiff * toMs('hour'));
143-
}
144-
if (sourceOffsets.endDate.appointment !== sourceOffsets.endDate.common) {
145-
a.endDate = new Date(a.endDate.getTime() + endDateOffsetDiff * toMs('hour'));
146-
}
156+
a.startDate = new Date(a.startDate.getTime() + startDateOffsetDiff * toMs('hour'));
157+
a.endDate = new Date(a.endDate.getTime() + endDateOffsetDiff * toMs('hour'));
147158
});
148159
}
149160

@@ -210,7 +221,7 @@ export class DateGeneratorBaseStrategy {
210221
return this.timeZoneCalculator.getOffsets(date).common;
211222
}
212223

213-
_getProcessedNotNativeTimezoneDates(appointmentList, appointment) {
224+
_getProcessedNativeTimezoneDates(appointmentList, appointment) {
214225
return appointmentList.map((item) => {
215226
let diffStartDateOffset = this._getCommonOffset(appointment.startDate) - this._getCommonOffset(item.startDate);
216227
let diffEndDateOffset = this._getCommonOffset(appointment.endDate) - this._getCommonOffset(item.endDate);
@@ -222,8 +233,8 @@ export class DateGeneratorBaseStrategy {
222233
diffStartDateOffset = this._getProcessedNotNativeDateIfCrossDST(item.startDate, diffStartDateOffset);
223234
diffEndDateOffset = this._getProcessedNotNativeDateIfCrossDST(item.endDate, diffEndDateOffset);
224235

225-
const newStartDate = new Date(item.startDate.getTime() + diffStartDateOffset * toMs('hour'));
226-
let newEndDate = new Date(item.endDate.getTime() + diffEndDateOffset * toMs('hour'));
236+
const newStartDate = new Date(item.startDate.getTime() - diffStartDateOffset * toMs('hour'));
237+
let newEndDate = new Date(item.endDate.getTime() - diffEndDateOffset * toMs('hour'));
227238

228239
const testNewStartDate = this.timeZoneCalculator.createDate(newStartDate, 'toGrid');
229240
const testNewEndDate = this.timeZoneCalculator.createDate(newEndDate, 'toGrid');
@@ -310,7 +321,7 @@ export class DateGeneratorBaseStrategy {
310321
return result;
311322
}
312323

313-
_createGridAppointmentList(appointmentList, appointmentAdapter) {
324+
excludeLocalDST(appointmentList, appointmentAdapter) {
314325
return appointmentList.map((source) => {
315326
const offsetDifference = appointmentAdapter.startDate.getTimezoneOffset() - source.startDate.getTimezoneOffset();
316327

@@ -319,6 +330,12 @@ export class DateGeneratorBaseStrategy {
319330
source.endDate = dateUtilsTs.addOffsets(source.endDate, [offsetDifference * toMs('minute')]);
320331
}
321332

333+
return source;
334+
});
335+
}
336+
337+
_createGridAppointmentList(appointmentList, sourceList, appointmentAdapter) {
338+
return appointmentList.map((source, index) => {
322339
const duration = source.endDate.getTime() - source.startDate.getTime();
323340
const startDate = this.timeZoneCalculator.createDate(source.startDate, 'toGrid');
324341
const endDate = dateUtilsTs.addOffsets(startDate, [duration]);
@@ -327,7 +344,7 @@ export class DateGeneratorBaseStrategy {
327344
startDate,
328345
endDate,
329346
allDay: appointmentAdapter.allDay || false,
330-
source, // TODO
347+
source: sourceList[index],
331348
};
332349
});
333350
}

packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/m_view_model_generator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class AppointmentViewModelGenerator {
5959
});
6060
const viewModelPlain = appointmentRenderingStrategyName === 'agenda'
6161
? adaptAgendaSettings(viewModel, dataAccessors, timeZoneCalculator)
62-
: addCollector(viewModel, timeZoneCalculator);
62+
: addCollector(viewModel);
6363

6464
return {
6565
positionMap,

0 commit comments

Comments
 (0)