Skip to content

Commit d5db44a

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

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
@@ -751,21 +751,37 @@ class SchedulerAppointments extends CollectionWidget {
751751
});
752752
}
753753

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

757-
const modifiedAppointmentAdapter = createAppointmentAdapter(
757+
const gridAdapter = createAppointmentAdapter(
758758
sourceAppointment,
759759
dataAccessors,
760760
timeZoneCalculator,
761-
).clone();
761+
);
762+
763+
gridAdapter.startDate = new Date(dateRange.startDate);
764+
gridAdapter.endDate = new Date(dateRange.endDate);
765+
766+
/*
767+
* T1255474. `dateRange` has dates with 00:00 local time.
768+
* If we transform dates fromGrid and back through DST then we'll lose one hour.
769+
* TODO(1): refactor computation around DST globally
770+
*/
771+
const convertedBackAdapter = gridAdapter.clone();
772+
convertedBackAdapter
773+
.calculateDates('fromGrid')
774+
.calculateDates('toGrid');
775+
776+
const startDateDelta = gridAdapter.startDate.getTime() - convertedBackAdapter.startDate.getTime();
777+
const endDateDelta = gridAdapter.endDate.getTime() - convertedBackAdapter.endDate.getTime();
762778

763-
modifiedAppointmentAdapter.startDate = new Date(dateRange.startDate);
764-
modifiedAppointmentAdapter.endDate = new Date(dateRange.endDate);
779+
gridAdapter.startDate = dateUtilsTs.addOffsets(gridAdapter.startDate, [startDateDelta]);
780+
gridAdapter.endDate = dateUtilsTs.addOffsets(gridAdapter.endDate, [endDateDelta]);
765781

766782
this.notifyObserver('updateAppointmentAfterResize', {
767783
target: sourceAppointment,
768-
data: modifiedAppointmentAdapter.clone({ pathTimeZone: 'fromGrid' } as any).source(),
784+
data: gridAdapter.calculateDates('fromGrid').source(),
769785
$appointment: $element,
770786
});
771787
}

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, false, false, false, true),
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,7 +1,7 @@
11
import {
22
beforeEach, describe, expect, it, jest,
33
} from '@jest/globals';
4-
import dateUtils from '@js/core/utils/date';
4+
import { createTimeZoneCalculator } from '@ts/scheduler/r1/timezone_calculator';
55

66
import { TimeZoneCalculator } from '../calculator';
77
import type { PathTimeZoneConversion } from '../const';
@@ -46,94 +46,48 @@ describe('TimeZoneCalculator', () => {
4646
});
4747

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

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

126-
expect(() => {
127-
calculator.createDate(
128-
sourceDate,
129-
{
130-
path: 'WrongPath' as PathTimeZoneConversion,
131-
appointmentTimeZone: appointmentTimezone,
132-
},
133-
);
134-
})
135-
.toThrow('not specified pathTimeZoneConversion');
136-
});
81+
expect(() => {
82+
calculator.createDate(
83+
sourceDate,
84+
{
85+
path: 'WrongPath' as PathTimeZoneConversion,
86+
appointmentTimeZone: 'America/Los_Angeles',
87+
},
88+
);
89+
})
90+
.toThrow('not specified pathTimeZoneConversion');
13791
});
13892
});
13993

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)