Skip to content

Commit

Permalink
Make rotation start and rotation end take timezone into consideration (
Browse files Browse the repository at this point in the history
…#4481)

# What this PR does

- Fixes Rotation Start and Rotation End to take selected offset into
consideration
- Fixed issue where week/month period was being restored when the offset
was being changed (now it defaults to start of week, midnight, in
selected timezone offset)

Related to #4428
  • Loading branch information
teodosii authored Jun 17, 2024
1 parent 0704127 commit c3094be
Show file tree
Hide file tree
Showing 14 changed files with 221 additions and 151 deletions.
4 changes: 2 additions & 2 deletions src/containers/Rotation/Rotation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ interface RotationProps {

export const Rotation: FC<RotationProps> = observer((props) => {
const {
timezoneStore: { calendarStartDate, getDateInSelectedTimezone },
timezoneStore: { calendarStartDate, getDateInSelectedTimezone, selectedTimezoneOffset },
scheduleStore: { scheduleView: storeScheduleView },
} = useStore();
const {
Expand Down Expand Up @@ -144,7 +144,7 @@ export const Rotation: FC<RotationProps> = observer((props) => {
const base = 60 * 60 * 24 * days;

return firstShiftOffset / base;
}, [events, startDate]);
}, [events, startDate, selectedTimezoneOffset]);

return (
<div className={cx('root')} onClick={onClick && handleRotationClick}>
Expand Down
21 changes: 1 addition & 20 deletions src/containers/RotationForm/RotationForm.helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,12 @@ import dayjs, { Dayjs } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';

import { dayJSAddWithDSTFixed, getDateForDatePicker } from './RotationForm.helpers';
import { dayJSAddWithDSTFixed } from './RotationForm.helpers';

dayjs.extend(timezone);
dayjs.extend(utc);

describe('RotationForm helpers', () => {
describe('getDateForDatePicker()', () => {
it(`should return the same regular JS Date as input dayJsDate
even if selected day of month doesn't exist in current month
(in this case there is no 30th Feb and it should still work ok)`, () => {
jest.useFakeTimers().setSystemTime(new Date('2024-02-01'));

const inputDate = dayjs()
.utcOffset(360)
.set('year', 2024)
.set('month', 3) // 0-indexed so April
.set('date', 30)
.set('hour', 12)
.set('minute', 20);
const result = getDateForDatePicker(inputDate);

expect(result.toString()).toContain('Tue Apr 30 2024');
});
});

describe('dayJSAddWithDSTFixed() @london-tz', () => {
it(`corrects resulting hour to be the same as in input if start date is before London DST (GMT + 0)
and resulting date is within London DST (GMT + 1)`, () => {
Expand Down
34 changes: 1 addition & 33 deletions src/containers/RotationForm/RotationForm.helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import dayjs, { Dayjs, ManipulateType } from 'dayjs';

import { Timezone } from 'models/timezone/timezone.types';
import { Dayjs, ManipulateType } from 'dayjs';

import { RepeatEveryPeriod } from './RotationForm.types';

Expand All @@ -11,19 +9,6 @@ export const getRepeatShiftsEveryOptions = (repeatEveryPeriod: number) => {
.map((i) => ({ label: String(i), value: i }));
};

export const toDate = (moment: dayjs.Dayjs, timezone: Timezone) => {
const localMoment = moment.tz(timezone);

return new Date(
localMoment.get('year'),
localMoment.get('month'),
localMoment.get('date'),
localMoment.get('hour'),
localMoment.get('minute'),
localMoment.get('second')
);
};

export interface TimeUnit {
unit: RepeatEveryPeriod;
value: number;
Expand Down Expand Up @@ -171,23 +156,6 @@ export const repeatEveryInSeconds = (repeatEveryPeriod: RepeatEveryPeriod, repea
return repeatEveryPeriodMultiplier[repeatEveryPeriod] * repeatEveryValue;
};

export const getDateForDatePicker = (dayJsDate: Dayjs) => {
const date = new Date();
// Day of the month needs to be set to 1st day at first to prevent incorrect month increment
// when selected day of month doesn't exist in current month
// E.g. selected date is 30th March and current month is Feb, so in this case date.setMonth(2) results in April

date.setDate(1); // temporary selection to prevent incorrect month increment

date.setFullYear(dayJsDate.year());
date.setMonth(dayJsDate.month());
date.setDate(dayJsDate.date());
date.setHours(dayJsDate.hour());
date.setMinutes(dayJsDate.minute());
date.setSeconds(dayJsDate.second());
return date;
};

export const dayJSAddWithDSTFixed = ({
baseDate,
addParams,
Expand Down
103 changes: 70 additions & 33 deletions src/containers/RotationForm/RotationForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';

import {
Alert,
Button,
Field,
HorizontalGroup,
Expand Down Expand Up @@ -52,11 +53,11 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
import {
getDateTime,
getSelectedDays,
getStartOfWeekBasedOnCurrentDate,
getUTCByDay,
getUTCString,
getUTCWeekStart,
getWeekStartString,
toDateWithTimezoneOffset,
} from 'pages/schedule/Schedule.helpers';
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
import { useStore } from 'state/useStore';
Expand All @@ -82,8 +83,25 @@ interface RotationFormProps {
onShowRotationForm: (shiftId: Shift['id']) => void;
}

const getStartShift = (start: dayjs.Dayjs, timezoneOffset: number, isNewRotation = false) => {
if (isNewRotation) {
// all new rotations default to midnight in selected timezone offset
return toDateWithTimezoneOffset(start, timezoneOffset)
.set('date', 1)
.set('year', start.year())
.set('month', start.month())
.set('date', start.date())
.set('hour', 0)
.set('minute', 0)
.set('second', 0);
}

return toDateWithTimezoneOffset(start, timezoneOffset);
};

export const RotationForm = observer((props: RotationFormProps) => {
const store = useStore();

const {
onHide,
onCreate,
Expand All @@ -92,7 +110,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
onDelete,
layerPriority,
shiftId,
shiftStart: propsShiftStart = getStartOfWeekBasedOnCurrentDate(store.timezoneStore.currentDateInSelectedTimezone),
shiftStart: propsShiftStart = store.timezoneStore.calendarStartDate,
shiftEnd: propsShiftEnd,
shiftColor = '#3D71D9',
onShowRotationForm,
Expand All @@ -101,7 +119,6 @@ export const RotationForm = observer((props: RotationFormProps) => {
const shift = store.scheduleStore.shifts[shiftId];

const [errors, setErrors] = useState<{ [key: string]: string[] }>({});

const [bounds, setDraggableBounds] = useState<{ left: number; right: number; top: number; bottom: number }>(
undefined
);
Expand All @@ -111,13 +128,19 @@ export const RotationForm = observer((props: RotationFormProps) => {
const [offsetTop, setOffsetTop] = useState<number>(GRAFANA_HEADER_HEIGHT + 10);
const [draggablePosition, setDraggablePosition] = useState<{ x: number; y: number }>(undefined);

const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(propsShiftStart);
const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(propsShiftEnd || shiftStart.add(1, 'day'));
const [shiftStart, setShiftStart] = useState<dayjs.Dayjs>(
getStartShift(propsShiftStart, store.timezoneStore.selectedTimezoneOffset, shiftId === 'new')
);

const [shiftEnd, setShiftEnd] = useState<dayjs.Dayjs>(
propsShiftEnd?.utcOffset(store.timezoneStore.selectedTimezoneOffset) || shiftStart.add(1, 'day')
);

const [activePeriod, setActivePeriod] = useState<number | undefined>(undefined);
const [shiftPeriodDefaultValue, setShiftPeriodDefaultValue] = useState<number | undefined>(undefined);

const [rotationStart, setRotationStart] = useState<dayjs.Dayjs>(shiftStart);
const [endLess, setEndless] = useState<boolean>(true);
const [endLess, setEndless] = useState<boolean>(shift?.until === undefined ? true : !Boolean(shift.until));
const [rotationEnd, setRotationEnd] = useState<dayjs.Dayjs>(shiftStart.add(1, 'month'));

const [repeatEveryValue, setRepeatEveryValue] = useState<number>(1);
Expand Down Expand Up @@ -248,10 +271,11 @@ export const RotationForm = observer((props: RotationFormProps) => {
shift,
endLess,
rotationName,
store.timezoneStore.selectedTimezoneOffset,
]
);

useEffect(handleChange, [params, store.timezoneStore.calendarStartDate]);
useEffect(handleChange, [params, store.timezoneStore.calendarStartDate, store.timezoneStore.selectedTimezoneOffset]);

const create = useCallback(async () => {
try {
Expand Down Expand Up @@ -330,28 +354,22 @@ export const RotationForm = observer((props: RotationFormProps) => {
}
};

const handleRotationStartChange = useCallback(
(value) => {
setRotationStart(value);
setShiftStart(value);
if (showActiveOnSelectedPartOfDay) {
setShiftEnd(
dayJSAddWithDSTFixed({
const handleRotationStartChange = (value: dayjs.Dayjs) => {
setRotationStart(value);
setShiftStart(value);

setShiftEnd(
showActiveOnSelectedPartOfDay
? dayJSAddWithDSTFixed({
baseDate: value,
addParams: [activePeriod, 'seconds'],
})
);
} else {
setShiftEnd(
dayJSAddWithDSTFixed({
: dayJSAddWithDSTFixed({
baseDate: value,
addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]],
})
);
}
},
[showActiveOnSelectedPartOfDay, activePeriod, repeatEveryPeriod, repeatEveryValue]
);
);
};

const handleActivePeriodChange = useCallback(
(value) => {
Expand Down Expand Up @@ -435,15 +453,23 @@ export const RotationForm = observer((props: RotationFormProps) => {
useEffect(() => {
if (shift) {
setRotationName(getShiftName(shift));
const shiftStart = getDateTime(shift.shift_start);

// use shiftStart as rotationStart for existing shifts
// (original rotationStart defaulted to the shift creation timestamp)
const shiftStart = toDateWithTimezoneOffset(dayjs(shift.shift_start), store.timezoneStore.selectedTimezoneOffset);

setRotationStart(shiftStart);
setRotationEnd(shift.until ? getDateTime(shift.until) : getDateTime(shift.shift_start).add(1, 'month'));
setRotationEnd(
toDateWithTimezoneOffset(
// always keep the date offseted
shift.until ? getDateTime(shift.until) : getDateTime(shift.shift_start).add(1, 'month'),
store.timezoneStore.selectedTimezoneOffset
)
);
setShiftStart(shiftStart);
const shiftEnd = getDateTime(shift.shift_end);

const shiftEnd = toDateWithTimezoneOffset(dayjs(shift.shift_end), store.timezoneStore.selectedTimezoneOffset);
setShiftEnd(shiftEnd);
setEndless(!shift.until);

setRepeatEveryValue(shift.interval);
setRepeatEveryPeriod(shift.frequency);
Expand Down Expand Up @@ -475,13 +501,25 @@ export const RotationForm = observer((props: RotationFormProps) => {

useEffect(() => {
if (shift) {
// for existing rotations
handleRotationStartChange(toDateWithTimezoneOffset(rotationStart, store.timezoneStore.selectedTimezoneOffset));
setRotationEnd(toDateWithTimezoneOffset(rotationEnd, store.timezoneStore.selectedTimezoneOffset));

setSelectedDays(
getSelectedDays({
dayOptions: store.scheduleStore.byDayOptions,
by_day: shift.by_day,
moment: store.timezoneStore.getDateInSelectedTimezone(shiftStart),
})
);
} else {
// for new rotations
handleRotationStartChange(toDateWithTimezoneOffset(rotationStart, store.timezoneStore.selectedTimezoneOffset));

setShiftEnd(toDateWithTimezoneOffset(shiftEnd, store.timezoneStore.selectedTimezoneOffset));

// not behind an "if" such that it will reflect correct value after toggle gets switched
setRotationEnd(toDateWithTimezoneOffset(rotationEnd, store.timezoneStore.selectedTimezoneOffset));
}
}, [store.timezoneStore.selectedTimezoneOffset]);

Expand Down Expand Up @@ -561,14 +599,11 @@ export const RotationForm = observer((props: RotationFormProps) => {
</Block>
)}
{!hasUpdatedShift && ended && (
<Block bordered className={cx('updated-shift-info')}>
<div className={cx('updated-shift-info')}>
<VerticalGroup>
<HorizontalGroup>
<Icon name="info-circle" size="md"></Icon>
<Text>This rotation is over</Text>
</HorizontalGroup>
<Alert severity="info" title={(<Text>This rotation is over</Text>) as unknown as string} />
</VerticalGroup>
</Block>
</div>
)}
<div className={cx('two-fields')}>
<Field
Expand All @@ -580,6 +615,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
>
<DateTimePicker
value={rotationStart}
utcOffset={store.timezoneStore.selectedTimezoneOffset}
onChange={handleRotationStartChange}
error={errors.rotation_start}
disabled={disabled}
Expand Down Expand Up @@ -608,6 +644,7 @@ export const RotationForm = observer((props: RotationFormProps) => {
) : (
<DateTimePicker
value={rotationEnd}
utcOffset={store.timezoneStore.selectedTimezoneOffset}
onChange={setRotationEnd}
error={errors.until}
disabled={disabled}
Expand Down
2 changes: 1 addition & 1 deletion src/containers/RotationForm/ScheduleOverrideForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export const ScheduleOverrideForm: FC<RotationFormProps> = (props) => {

const handleChange = useDebouncedCallback(updatePreview, 200);

useEffect(handleChange, [params, store.timezoneStore.calendarStartDate]);
useEffect(handleChange, [params, store.timezoneStore.calendarStartDate, store.timezoneStore.selectedTimezoneOffset]);

const isFormValid = useMemo(() => !Object.keys(errors).length, [errors]);

Expand Down
2 changes: 1 addition & 1 deletion src/containers/RotationForm/ShiftSwapForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const ShiftSwapForm = (props: ShiftSwapFormProps) => {
...shiftSwap,
});
}
}, [shiftSwap, store.timezoneStore.calendarStartDate]);
}, [shiftSwap, store.timezoneStore.calendarStartDate, store.timezoneStore.selectedTimezoneOffset]);

const handleDescriptionChange = useCallback(
(event) => {
Expand Down
Loading

0 comments on commit c3094be

Please sign in to comment.