Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @timezone Europe/Belgrade
*/

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<string[]>((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],
firstDayOfWeek: 3,
});

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],
firstDayOfWeek: 3,
});

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],
firstDayOfWeek: 3,
});

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',
]);
});

it('should change dates according to DST in target (Local Belgrade) and appointment timezones (T1305659)', async () => {
setupSchedulerTestEnvironment();
const { POM, scheduler } = await createScheduler({
dataSource: [dailyAppointment],
views,
currentView: 'week',
currentDate: ChicagoDST[0],
firstDayOfWeek: 3,
});

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',
]);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { isDefined } from '@js/core/utils/type';
import type { TimeZoneCalculator } from '@ts/scheduler/r1/timezone_calculator';
import type { SafeAppointment } from '@ts/scheduler/types';

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

export const addCollector = (
viewModel: AppointmentViewModelInternal[],
timeZoneCalculator: TimeZoneCalculator,
): AppointmentViewModelPlain[] => {
const internalViewModelItems = plainViewModel(viewModel);
const result: AppointmentViewModelPlain[] = [];
Expand All @@ -86,8 +84,8 @@ export const addCollector = (
sourceAppointment: item.info.sourceAppointment,
appointment: {
...item.info.appointment,
startDate: timeZoneCalculator.createDate(item.info.sourceAppointment.startDate, 'toGrid'),
endDate: timeZoneCalculator.createDate(item.info.sourceAppointment.endDate, 'toGrid'),
startDate: item.info.appointment.savedBeforeSplit.startDate,
endDate: item.info.appointment.savedBeforeSplit.endDate,
},
},
})).forEach((item) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import dateUtils from '@js/core/utils/date';
import { extend } from '@js/core/utils/extend';
import { isEmptyObject } from '@js/core/utils/type';
import { dateUtilsTs } from '@ts/core/utils/date';
import { getAsciiStringByDate } from '@ts/scheduler/recurrence/base';

import { createFormattedDateText } from '../../appointments/m_text_utils';
import timeZoneUtils from '../../m_utils_time_zone';
Expand Down Expand Up @@ -78,16 +79,48 @@ export class DateGeneratorBaseStrategy {
const { isRecurrent } = appointmentAdapter;

const itemGroupIndices = this._getGroupIndices(this.rawAppointment);
let dateSettings;

let appointmentList = this._createAppointments(appointmentAdapter, itemGroupIndices);
if (appointmentAdapter.isRecurrent && appointmentAdapter.startDateTimeZone) {
let sourceList = this._createAppointments(appointmentAdapter, itemGroupIndices);
sourceList = this.excludeLocalDST(sourceList, appointmentAdapter);
sourceList = this._getProcessedByAppointmentTimeZone(sourceList, appointmentAdapter); // T983264

appointmentList = this._getProcessedByAppointmentTimeZone(appointmentList, appointmentAdapter); // T983264
if (appointmentAdapter.isRecurrent && appointmentAdapter.recurrenceException) {
const exceptions = new Set(appointmentAdapter.recurrenceException?.split(','));
sourceList = sourceList
.filter((item) => !exceptions.has(getAsciiStringByDate(item.startDate)));
}

let appointmentList = sourceList;
if (appointmentAdapter.isRecurrent && (
!this.timeZone
|| timeZoneUtils.isEqualLocalTimeZone(this.timeZone)
)) {
appointmentList = this._getProcessedNativeTimezoneDates(appointmentList, appointmentAdapter);
}

if (this._canProcessNotNativeTimezoneDates(appointmentAdapter)) {
appointmentList = this._getProcessedNotNativeTimezoneDates(appointmentList, appointmentAdapter);
dateSettings = this._createGridAppointmentList(appointmentList, sourceList, appointmentAdapter);
} else {
let appointmentList = this._createAppointments(appointmentAdapter, itemGroupIndices);

appointmentList = this._getProcessedByAppointmentTimeZone(appointmentList, appointmentAdapter); // T983264

if (this._canProcessNotNativeTimezoneDates(appointmentAdapter)) {
appointmentList = this._getProcessedNotNativeTimezoneDates(appointmentList, appointmentAdapter);
}

appointmentList = this.excludeLocalDST(appointmentList, appointmentAdapter);
dateSettings = this._createGridAppointmentList(appointmentList, appointmentList, appointmentAdapter);
}

let dateSettings = this._createGridAppointmentList(appointmentList, appointmentAdapter);
dateSettings = dateSettings.map((item) => ({
...item,
savedBeforeSplit: {
startDate: new Date(item.startDate),
endDate: new Date(item.endDate),
},
}));

const firstViewDates = this._getAppointmentsFirstViewDate(dateSettings);

Expand Down Expand Up @@ -135,21 +168,51 @@ export class DateGeneratorBaseStrategy {
endDate: this.timeZoneCalculator.getOffsets(a.endDate, appointment.endDateTimeZone),
};

const startDateOffsetDiff = appointmentOffsets.startDate.appointment - sourceOffsets.startDate.appointment;
const endDateOffsetDiff = appointmentOffsets.endDate.appointment - sourceOffsets.endDate.appointment;
const startDateOffsetDiff = appointment.startDateTimeZone
? appointmentOffsets.startDate.appointment - sourceOffsets.startDate.appointment
: 0;
const endDateOffsetDiff = appointment.endDateTimeZone
? appointmentOffsets.endDate.appointment - sourceOffsets.endDate.appointment
: 0;

if (sourceOffsets.startDate.appointment !== sourceOffsets.startDate.common) {
a.startDate = new Date(a.startDate.getTime() + startDateOffsetDiff * toMs('hour'));
}
if (sourceOffsets.endDate.appointment !== sourceOffsets.endDate.common) {
a.endDate = new Date(a.endDate.getTime() + endDateOffsetDiff * toMs('hour'));
}
a.startDate = new Date(a.startDate.getTime() + startDateOffsetDiff * toMs('hour'));
a.endDate = new Date(a.endDate.getTime() + endDateOffsetDiff * toMs('hour'));
});
}

return appointmentList;
}

_getProcessedNotNativeTimezoneDates(appointmentList, appointment) {
return appointmentList.map((item) => {
let diffStartDateOffset = this._getCommonOffset(appointment.startDate) - this._getCommonOffset(item.startDate);
let diffEndDateOffset = this._getCommonOffset(appointment.endDate) - this._getCommonOffset(item.endDate);

if (diffStartDateOffset === 0 && diffEndDateOffset === 0) {
return item;
}

diffStartDateOffset = this._getProcessedNotNativeDateIfCrossDST(item.startDate, diffStartDateOffset);
diffEndDateOffset = this._getProcessedNotNativeDateIfCrossDST(item.endDate, diffEndDateOffset);

const newStartDate = new Date(item.startDate.getTime() + diffStartDateOffset * toMs('hour'));
let newEndDate = new Date(item.endDate.getTime() + diffEndDateOffset * toMs('hour'));

const testNewStartDate = this.timeZoneCalculator.createDate(newStartDate, 'toGrid');
const testNewEndDate = this.timeZoneCalculator.createDate(newEndDate, 'toGrid');

if (appointment.duration > testNewEndDate.getTime() - testNewStartDate.getTime()) {
newEndDate = new Date(newStartDate.getTime() + appointment.duration);
}

return {
...item,
startDate: newStartDate,
endDate: newEndDate,
};
});
}

_createAppointments(appointment, groupIndices) {
let appointments = this._createRecurrenceAppointments(appointment, groupIndices);

Expand Down Expand Up @@ -210,7 +273,7 @@ export class DateGeneratorBaseStrategy {
return this.timeZoneCalculator.getOffsets(date).common;
}

_getProcessedNotNativeTimezoneDates(appointmentList, appointment) {
_getProcessedNativeTimezoneDates(appointmentList, appointment) {
return appointmentList.map((item) => {
let diffStartDateOffset = this._getCommonOffset(appointment.startDate) - this._getCommonOffset(item.startDate);
let diffEndDateOffset = this._getCommonOffset(appointment.endDate) - this._getCommonOffset(item.endDate);
Expand All @@ -222,8 +285,8 @@ export class DateGeneratorBaseStrategy {
diffStartDateOffset = this._getProcessedNotNativeDateIfCrossDST(item.startDate, diffStartDateOffset);
diffEndDateOffset = this._getProcessedNotNativeDateIfCrossDST(item.endDate, diffEndDateOffset);

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

const testNewStartDate = this.timeZoneCalculator.createDate(newStartDate, 'toGrid');
const testNewEndDate = this.timeZoneCalculator.createDate(newEndDate, 'toGrid');
Expand Down Expand Up @@ -281,7 +344,7 @@ export class DateGeneratorBaseStrategy {

gridAppointmentList.forEach((gridAppointment) => {
const maxDate = new Date(this.dateRange[1]);
const { startDate, normalizedEndDate: endDateOfPart } = gridAppointment;
const { startDate, normalizedEndDate: endDateOfPart, savedBeforeSplit } = gridAppointment;

const longStartDateParts = dateUtils.getDatesOfInterval(
startDate,
Expand All @@ -300,6 +363,7 @@ export class DateGeneratorBaseStrategy {
startDate: date,
endDate,
normalizedEndDate,
savedBeforeSplit,
source: gridAppointment.source,
};
});
Expand All @@ -310,7 +374,7 @@ export class DateGeneratorBaseStrategy {
return result;
}

_createGridAppointmentList(appointmentList, appointmentAdapter) {
excludeLocalDST(appointmentList, appointmentAdapter) {
return appointmentList.map((source) => {
const offsetDifference = appointmentAdapter.startDate.getTimezoneOffset() - source.startDate.getTimezoneOffset();

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

return source;
});
}

_createGridAppointmentList(appointmentList, sourceList, appointmentAdapter) {
return appointmentList.map((source, index) => {
const duration = source.endDate.getTime() - source.startDate.getTime();
const startDate = this.timeZoneCalculator.createDate(source.startDate, 'toGrid');
const endDate = dateUtilsTs.addOffsets(startDate, [duration]);
Expand All @@ -327,7 +397,7 @@ export class DateGeneratorBaseStrategy {
startDate,
endDate,
allDay: appointmentAdapter.allDay || false,
source, // TODO
source: sourceList[index],
};
});
}
Expand Down
Loading
Loading