Skip to content

Commit 5e2bae6

Browse files
authored
T1255474: fix date calculation if new date is in different timezone because of DST (DevExpress#28974)
Co-authored-by: Vladimir Bushmanov <[email protected]>
1 parent ebb3f4a commit 5e2bae6

File tree

5 files changed

+119
-130
lines changed

5 files changed

+119
-130
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { compareScreenshot } from 'devextreme-screenshot-comparer';
2+
import Scheduler from 'devextreme-testcafe-models/scheduler';
3+
import { getTimezoneTest, MACHINE_TIMEZONES } from '../../../helpers/machineTimezones';
4+
import url from '../../../helpers/getPageUrl';
5+
import createScheduler from './init/widget.setup';
6+
7+
fixture.disablePageReloads`Resize appointment that cross DTC time`
8+
.page(url(__dirname, '../../container.html'));
9+
10+
const appointmentText = 'Book Flights to San Fran for Sales Trip';
11+
12+
getTimezoneTest([MACHINE_TIMEZONES.EuropeBerlin])('Resize appointment that cross DTC time', async (t) => {
13+
const scheduler = new Scheduler('#container');
14+
const appointment = scheduler.getAppointment(appointmentText);
15+
16+
await t
17+
.drag(appointment.resizableHandle.right, 100, 0)
18+
.drag(appointment.resizableHandle.right, -100, 0)
19+
.expect(
20+
await compareScreenshot(t, 'T1255474-resize-all-day-appointment.png', scheduler.element),
21+
)
22+
.ok();
23+
}).before(async () => createScheduler({
24+
timeZone: 'America/Los_Angeles',
25+
views: ['week'],
26+
currentView: 'week',
27+
currentDate: new Date(2021, 2, 28),
28+
allDayPanelMode: 'allDay',
29+
height: 600,
30+
width: 800,
31+
firstDayOfWeek: 7,
32+
dataSource: [{
33+
text: appointmentText,
34+
startDate: new Date('2021-03-28T17:00:00.000Z'),
35+
endDate: new Date('2021-03-28T18:00:00.000Z'),
36+
TimeZone: 'Europe/Belgrade',
37+
allDay: true,
38+
}],
39+
}));

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -716,21 +716,37 @@ class SchedulerAppointments extends CollectionWidget {
716716
});
717717
}
718718

719-
updateResizedAppointment($element, dateRange, dataAccessors, timeZoneCalculator) {
719+
updateResizedAppointment($element, dateRange: { startDate: Date; endDate: Date }, dataAccessors, timeZoneCalculator) {
720720
const sourceAppointment = (this as any)._getItemData($element);
721721

722-
const modifiedAppointmentAdapter = createAppointmentAdapter(
722+
const gridAdapter = createAppointmentAdapter(
723723
sourceAppointment,
724724
dataAccessors,
725725
timeZoneCalculator,
726-
).clone();
726+
);
727+
728+
gridAdapter.startDate = new Date(dateRange.startDate);
729+
gridAdapter.endDate = new Date(dateRange.endDate);
730+
731+
/*
732+
* T1255474. `dateRange` has dates with 00:00 local time.
733+
* If we transform dates fromGrid and back through DST then we'll lose one hour.
734+
* TODO(1): refactor computation around DST globally
735+
*/
736+
const convertedBackAdapter = gridAdapter.clone();
737+
convertedBackAdapter
738+
.calculateDates('fromGrid')
739+
.calculateDates('toGrid');
740+
741+
const startDateDelta = gridAdapter.startDate.getTime() - convertedBackAdapter.startDate.getTime();
742+
const endDateDelta = gridAdapter.endDate.getTime() - convertedBackAdapter.endDate.getTime();
727743

728-
modifiedAppointmentAdapter.startDate = new Date(dateRange.startDate);
729-
modifiedAppointmentAdapter.endDate = new Date(dateRange.endDate);
744+
gridAdapter.startDate = dateUtilsTs.addOffsets(gridAdapter.startDate, [startDateDelta]);
745+
gridAdapter.endDate = dateUtilsTs.addOffsets(gridAdapter.endDate, [endDateDelta]);
730746

731747
this.notifyObserver('updateAppointmentAfterResize', {
732748
target: sourceAppointment,
733-
data: modifiedAppointmentAdapter.clone({ pathTimeZone: 'fromGrid' } as any).source(),
749+
data: gridAdapter.calculateDates('fromGrid').source(),
734750
$appointment: $element,
735751
});
736752
}

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ class AppointmentAdapter {
145145
});
146146
}
147147

148-
clone(options: any = undefined) {
148+
clone(options?: { pathTimeZone: string }) {
149149
const result = new AppointmentAdapter(
150150
deepExtendArraySafe({}, this.rawAppointment),
151151
this.dataAccessors,
@@ -154,13 +154,19 @@ class AppointmentAdapter {
154154
);
155155

156156
if (options?.pathTimeZone) {
157-
result.startDate = result.calculateStartDate(options.pathTimeZone);
158-
result.endDate = result.calculateEndDate(options.pathTimeZone);
157+
result.calculateDates(options.pathTimeZone);
159158
}
160159

161160
return result;
162161
}
163162

163+
calculateDates(pathTimeZoneConversion) {
164+
this.startDate = this.calculateStartDate(pathTimeZoneConversion);
165+
this.endDate = this.calculateEndDate(pathTimeZoneConversion);
166+
167+
return this;
168+
}
169+
164170
source(serializeDate = false) {
165171
if (serializeDate) {
166172
// TODO: hack for use dateSerializationFormat

packages/devextreme/js/__internal/scheduler/r1/timezone_calculator/__tests__/timeZoneCalculator.test.ts

Lines changed: 39 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import dateUtils from '@js/core/utils/date';
1+
import { createTimeZoneCalculator } from '@ts/scheduler/r1/timezone_calculator';
22

33
import { TimeZoneCalculator } from '../calculator';
44
import type { PathTimeZoneConversion } from '../const';
@@ -43,94 +43,48 @@ describe('TimeZoneCalculator', () => {
4343
});
4444

4545
[
46-
'America/Los_Angeles',
47-
undefined,
48-
].forEach((appointmentTimezone) => {
49-
['toGrid', 'fromGrid'].forEach((path) => {
50-
test(`should use common time zone [path: ${path}
51-
if converting to common timezone, appointmentTimezone: ${appointmentTimezone}]`, () => {
52-
const calculator = new TimeZoneCalculator(mock);
53-
54-
const spy = jest.spyOn(calculator, 'getConvertedDateByOffsets');
55-
56-
calculator.createDate(
57-
sourceDate,
58-
{
59-
path: path as PathTimeZoneConversion,
60-
appointmentTimeZone: appointmentTimezone,
61-
},
62-
);
63-
64-
expect(spy)
65-
.toBeCalledTimes(1);
66-
67-
const isBackDirection = path === 'fromGrid';
68-
69-
expect(spy)
70-
.toBeCalledWith(
71-
sourceDate,
72-
-localOffset / dateUtils.dateToMilliseconds('hour'),
73-
commonOffset,
74-
isBackDirection,
75-
);
76-
});
46+
{ path: 'toGrid' as PathTimeZoneConversion, appointmentTimezone: 'America/Los_Angeles', timezone: 'common' },
47+
{ path: 'toGrid' as PathTimeZoneConversion, appointmentTimezone: undefined, timezone: 'common' },
48+
{ path: 'fromGrid' as PathTimeZoneConversion, appointmentTimezone: 'America/Los_Angeles', timezone: 'common' },
49+
{ path: 'fromGrid' as PathTimeZoneConversion, appointmentTimezone: undefined, timezone: 'common' },
50+
{ path: 'toAppointment' as PathTimeZoneConversion, appointmentTimezone: 'America/Los_Angeles', timezone: 'appointment' },
51+
{ path: 'toAppointment' as PathTimeZoneConversion, appointmentTimezone: undefined, timezone: 'common' },
52+
{ path: 'fromAppointment' as PathTimeZoneConversion, appointmentTimezone: 'America/Los_Angeles', timezone: 'appointment' },
53+
{ path: 'fromAppointment' as PathTimeZoneConversion, appointmentTimezone: undefined, timezone: 'common' },
54+
].forEach(({ path, appointmentTimezone, timezone }) => {
55+
test(`should use ${timezone} timezone [path: ${path}, appointmentTimezone: ${appointmentTimezone}]`, () => {
56+
const calculator = createTimeZoneCalculator('America/Los_Angeles');
57+
const clientMock = jest.fn().mockReturnValue(0);
58+
const commonMock = jest.fn().mockReturnValue(0);
59+
const appointmentMock = jest.fn().mockReturnValue(0);
60+
61+
jest.spyOn(calculator, 'getOffsets').mockImplementation(() => ({
62+
get client(): number { return clientMock() as number; },
63+
get common(): number { return commonMock() as number; },
64+
get appointment(): number { return appointmentMock() as number; },
65+
}));
66+
67+
calculator.createDate(sourceDate, { path, appointmentTimeZone: appointmentTimezone });
68+
69+
expect(clientMock).toHaveBeenCalledTimes(1);
70+
expect(commonMock).toHaveBeenCalledTimes(timezone === 'common' ? 1 : 0);
71+
expect(appointmentMock).toHaveBeenCalledTimes(timezone === 'appointment' ? 1 : 0);
7772
});
7873
});
7974

80-
[
81-
'America/Los_Angeles',
82-
undefined,
83-
].forEach((appointmentTimezone) => {
84-
[
85-
'toAppointment',
86-
'fromAppointment',
87-
].forEach((path) => {
88-
test(`if converting to appointment timezone, should use appointment time zone
89-
[path: ${path}, appointmentTimezone: ${appointmentTimezone}]`, () => {
90-
const calculator = new TimeZoneCalculator(mock);
91-
92-
const spy = jest.spyOn(calculator, 'getConvertedDateByOffsets');
93-
94-
calculator.createDate(
95-
sourceDate,
96-
{
97-
path: path as PathTimeZoneConversion,
98-
appointmentTimeZone: appointmentTimezone,
99-
},
100-
);
101-
102-
expect(spy)
103-
.toBeCalledTimes(1);
104-
105-
const isBackDirectionArg = path === 'fromAppointment';
106-
const commonOffsetArg = appointmentTimezone === undefined
107-
? commonOffset
108-
: appointmentOffset;
109-
110-
expect(spy)
111-
.toBeCalledWith(
112-
sourceDate,
113-
-localOffset / dateUtils.dateToMilliseconds('hour'),
114-
commonOffsetArg,
115-
isBackDirectionArg,
116-
);
117-
});
118-
});
119-
120-
test('createDate should throw error if wrong path', () => {
121-
const calculator = new TimeZoneCalculator(mock);
75+
test('createDate should throw error if wrong path', () => {
76+
const calculator = new TimeZoneCalculator(mock);
12277

123-
expect(() => {
124-
calculator.createDate(
125-
sourceDate,
126-
{
127-
path: 'WrongPath' as PathTimeZoneConversion,
128-
appointmentTimeZone: appointmentTimezone,
129-
},
130-
);
131-
})
132-
.toThrow('not specified pathTimeZoneConversion');
133-
});
78+
expect(() => {
79+
calculator.createDate(
80+
sourceDate,
81+
{
82+
path: 'WrongPath' as PathTimeZoneConversion,
83+
appointmentTimeZone: 'America/Los_Angeles',
84+
},
85+
);
86+
})
87+
.toThrow('not specified pathTimeZoneConversion');
13488
});
13589
});
13690

packages/devextreme/js/__internal/scheduler/r1/timezone_calculator/calculator.ts

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,16 @@ export class TimeZoneCalculator {
2424

2525
switch (info.path) {
2626
case PathTimeZoneConversion.fromSourceToAppointment:
27-
return this.getConvertedDate(date, info.appointmentTimeZone, true, false);
27+
return this.getConvertedDate(date, info.appointmentTimeZone, false);
2828

2929
case PathTimeZoneConversion.fromAppointmentToSource:
30-
return this.getConvertedDate(date, info.appointmentTimeZone, true, true);
30+
return this.getConvertedDate(date, info.appointmentTimeZone, true);
3131

3232
case PathTimeZoneConversion.fromSourceToGrid:
33-
return this.getConvertedDate(date, info.appointmentTimeZone, false, false);
33+
return this.getConvertedDate(date, undefined, false);
3434

3535
case PathTimeZoneConversion.fromGridToSource:
36-
return this.getConvertedDate(date, info.appointmentTimeZone, false, true);
36+
return this.getConvertedDate(date, undefined, true);
3737

3838
default:
3939
throw new Error('not specified pathTimeZoneConversion');
@@ -52,32 +52,6 @@ export class TimeZoneCalculator {
5252
};
5353
}
5454

55-
// QUnit tests are checked call of this method
56-
// eslint-disable-next-line class-methods-use-this
57-
getConvertedDateByOffsets(
58-
date: Date,
59-
clientOffset: number,
60-
targetOffset: number,
61-
isBack: boolean,
62-
): Date {
63-
const direction = isBack
64-
? -1
65-
: 1;
66-
67-
const resultDate = new Date(date);
68-
return dateUtilsTs.addOffsets(resultDate, [
69-
direction * (toMs('hour') * targetOffset),
70-
-direction * (toMs('hour') * clientOffset),
71-
]);
72-
73-
// V1
74-
// NOTE: Previous date calculation engine.
75-
// Engine was changed after fix T1078292.
76-
// eslint-disable-next-line max-len
77-
// const utcDate = date.getTime() - direction * clientOffset * dateUtils.dateToMilliseconds('hour');
78-
// return new Date(utcDate + direction * targetOffset * dateUtils.dateToMilliseconds('hour'));
79-
}
80-
8155
getOriginStartDateOffsetInMs(
8256
date: Date,
8357
timezone: string | undefined,
@@ -123,16 +97,16 @@ export class TimeZoneCalculator {
12397
protected getConvertedDate(
12498
date: Date,
12599
appointmentTimezone: string | undefined,
126-
useAppointmentTimeZone: boolean,
127100
isBack: boolean,
128101
): Date {
129102
const newDate = new Date(date.getTime());
130103
const offsets = this.getOffsets(newDate, appointmentTimezone);
104+
const targetOffsetName = appointmentTimezone ? 'appointment' : 'common';
105+
const direction = isBack ? -1 : 1;
131106

132-
if (useAppointmentTimeZone && !!appointmentTimezone) {
133-
return this.getConvertedDateByOffsets(date, offsets.client, offsets.appointment, isBack);
134-
}
135-
136-
return this.getConvertedDateByOffsets(date, offsets.client, offsets.common, isBack);
107+
return dateUtilsTs.addOffsets(newDate, [
108+
direction * toMs('hour') * offsets[targetOffsetName],
109+
-direction * toMs('hour') * offsets.client,
110+
]);
137111
}
138112
}

0 commit comments

Comments
 (0)