diff --git a/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts b/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts index 5afabeea..e1bd7f32 100644 --- a/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts +++ b/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts @@ -10,9 +10,8 @@ test(`user can see the other user's details`, async ({ adminRolePage, editorRole await createOnCallScheduleWithRotation(page, onCallScheduleName, adminUserName); await createRotation(page, editorUserName, false); + await page.waitForTimeout(1_000); + await page.getByTestId('user-avatar-in-schedule').first().hover(); await expect(page.getByTestId('schedule-user-details')).toHaveText(new RegExp(editorUserName)); - - await page.getByTestId('user-avatar-in-schedule').nth(1).hover(); - await expect(page.getByTestId('schedule-user-details')).toHaveText(new RegExp(adminUserName)); }); diff --git a/grafana-plugin/e2e-tests/schedules/timezones.test.ts b/grafana-plugin/e2e-tests/schedules/timezones.test.ts index f9d098bd..32f5689d 100644 --- a/grafana-plugin/e2e-tests/schedules/timezones.test.ts +++ b/grafana-plugin/e2e-tests/schedules/timezones.test.ts @@ -11,7 +11,9 @@ import { createOnCallScheduleWithRotation } from '../utils/schedule'; dayjs.extend(utc); dayjs.extend(isoWeek); -test.use({ timezoneId: 'Europe/Moscow' }); // GMT+3 the whole year +const MOSCOW_TIMEZONE = 'Europe/Moscow'; + +test.use({ timezoneId: MOSCOW_TIMEZONE }); // GMT+3 the whole year const currentUtcTimeHour = dayjs().utc().format('HH'); const currentUtcDate = dayjs().utc().format('DD MMM'); const currentMoscowTimeHour = dayjs().utcOffset(180).format('HH'); @@ -20,7 +22,7 @@ const currentMoscowDate = dayjs().utcOffset(180).format('DD MMM'); test('dates in schedule are correct according to selected current timezone', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; - await setTimezoneInProfile(page, 'Europe/Moscow'); + await setTimezoneInProfile(page, MOSCOW_TIMEZONE); const onCallScheduleName = generateRandomValue(); await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); @@ -41,7 +43,7 @@ test('dates in schedule are correct according to selected current timezone', asy await expect(page.getByTestId('schedule-user-details_your-current-time')).toHaveText( new RegExp(currentMoscowTimeHour) ); - await expect(page.getByTestId('schedule-user-details_user-local-time')).toHaveText(/GMT\+3/); + await expect(page.getByTestId('schedule-user-details_user-local-time')).toHaveText(new RegExp(MOSCOW_TIMEZONE)); await expect(page.getByTestId('schedule-user-details_user-local-time')).toHaveText(new RegExp(currentMoscowTimeHour)); // Schedule slot shows correct times and timezones @@ -50,7 +52,7 @@ test('dates in schedule are correct according to selected current timezone', asy await expect(page.getByTestId('schedule-slot-user-local-time')).toHaveText( new RegExp(`${currentMoscowDate}, ${currentMoscowTimeHour}`) ); - await expect(page.getByTestId('schedule-slot-user-local-time')).toHaveText(/\(GMT\+3\)/); + await expect(page.getByTestId('schedule-slot-user-local-time')).toHaveText(new RegExp(MOSCOW_TIMEZONE)); await expect(page.getByTestId('schedule-slot-current-timezone')).toHaveText( new RegExp(`${currentUtcDate}, ${currentUtcTimeHour}`) ); diff --git a/grafana-plugin/src/containers/Rotation/Rotation.tsx b/grafana-plugin/src/containers/Rotation/Rotation.tsx index 51ed0b7b..f75e738a 100644 --- a/grafana-plugin/src/containers/Rotation/Rotation.tsx +++ b/grafana-plugin/src/containers/Rotation/Rotation.tsx @@ -41,7 +41,7 @@ interface RotationProps { export const Rotation: FC = observer((props) => { const { - timezoneStore: { calendarStartDate }, + timezoneStore: { calendarStartDate, getDateInSelectedTimezone }, } = useStore(); const { events, @@ -133,11 +133,14 @@ export const Rotation: FC = observer((props) => { } const firstShift = events[0]; - const firstShiftOffset = dayjs(firstShift.start).diff(calendarStartDate, 'seconds'); + const firstShiftOffset = getDateInSelectedTimezone(firstShift.start).diff( + getDateInSelectedTimezone(calendarStartDate), + 'seconds' + ); const base = 60 * 60 * 24 * days; return firstShiftOffset / base; - }, [events]); + }, [events, calendarStartDate]); return (
diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts index 4876f8ad..c8aee870 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts @@ -187,3 +187,24 @@ export const getDateForDatePicker = (dayJsDate: Dayjs) => { date.setSeconds(dayJsDate.second()); return date; }; + +export const dayJSAddWithDSTFixed = ({ + baseDate, + addParams, +}: { + baseDate: Dayjs; + addParams: Parameters; +}) => { + // At first we add time as usual + let newDateCandidate = baseDate.add(...addParams); + + const differenceInHoursInLocalTimezone = newDateCandidate.hour() - baseDate.hour(); + const differenceInHoursInUTC = newDateCandidate.utc().hour() - baseDate.utc().hour(); + + // But if we identify that there was a DST change before base date and the result candidate + if (differenceInHoursInLocalTimezone !== differenceInHoursInUTC) { + // then we make the resulting date to ignore DST change + newDateCandidate = newDateCandidate.subtract(differenceInHoursInUTC, 'hours'); + } + return newDateCandidate; +}; diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index d7e5450f..c7f42b68 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -24,6 +24,7 @@ import { Text } from 'components/Text/Text'; import { UserGroups } from 'components/UserGroups/UserGroups'; import { RemoteSelect } from 'containers/RemoteSelect/RemoteSelect'; import { + dayJSAddWithDSTFixed, getRepeatShiftsEveryOptions, putDownMaxValues, reduceTheLastUnitValue, @@ -279,9 +280,19 @@ export const RotationForm = observer((props: RotationFormProps) => { if (!showActiveOnSelectedPartOfDay) { if (showActiveOnSelectedDays) { - setShiftEnd(shiftStart.add(24, 'hours')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [24, 'hours'], + }) + ); } else { - setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[value])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[value]], + }) + ); } } }, @@ -298,7 +309,12 @@ export const RotationForm = observer((props: RotationFormProps) => { setRepeatEveryValue(value); if (!showActiveOnSelectedPartOfDay) { - setShiftEnd(rotationStart.add(value, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: rotationStart, + addParams: [value, repeatEveryPeriodToUnitName[repeatEveryPeriod]], + }) + ); } }; @@ -307,9 +323,19 @@ export const RotationForm = observer((props: RotationFormProps) => { setRotationStart(value); setShiftStart(value); if (showActiveOnSelectedPartOfDay) { - setShiftEnd(value.add(activePeriod, 'seconds')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: value, + addParams: [activePeriod, 'seconds'], + }) + ); } else { - setShiftEnd(value.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: value, + addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]], + }) + ); } }, [showActiveOnSelectedPartOfDay, activePeriod, repeatEveryPeriod, repeatEveryValue] @@ -318,7 +344,12 @@ export const RotationForm = observer((props: RotationFormProps) => { const handleActivePeriodChange = useCallback( (value) => { setActivePeriod(value); - setShiftEnd(shiftStart.add(value, 'seconds')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [value, 'seconds'], + }) + ); }, [shiftStart] ); @@ -337,10 +368,20 @@ export const RotationForm = observer((props: RotationFormProps) => { setShowActiveOnSelectedDays(value); if (value) { - setShiftEnd(shiftStart.add(24, 'hours')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [24, 'hours'], + }) + ); } else { if (!showActiveOnSelectedPartOfDay) { - setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]], + }) + ); } } }, @@ -354,9 +395,19 @@ export const RotationForm = observer((props: RotationFormProps) => { if (!value) { if (showActiveOnSelectedPartOfDay) { - setShiftEnd(shiftStart.add(24, 'hours')); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [24, 'hours'], + }) + ); } else { - setShiftEnd(shiftStart.add(repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod])); + setShiftEnd( + dayJSAddWithDSTFixed({ + baseDate: shiftStart, + addParams: [repeatEveryValue, repeatEveryPeriodToUnitName[repeatEveryPeriod]], + }) + ); } } }, diff --git a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx index 1f69c3d1..6dc88a26 100644 --- a/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx +++ b/grafana-plugin/src/containers/RotationForm/parts/DateTimePicker.tsx @@ -1,7 +1,8 @@ import React from 'react'; +import { css } from '@emotion/css'; import { DateTime, dateTime } from '@grafana/data'; -import { DatePickerWithInput, TimeOfDayPicker, VerticalGroup } from '@grafana/ui'; +import { DatePickerWithInput, TimeOfDayPicker, useStyles2, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; @@ -25,6 +26,7 @@ interface DateTimePickerProps { export const DateTimePicker = observer( ({ value: propValue, onChange, disabled, onFocus, onBlur, error }: DateTimePickerProps) => { + const styles = useStyles2(getStyles); const { timezoneStore: { getDateInSelectedTimezone }, } = useStore(); @@ -61,7 +63,7 @@ export const DateTimePicker = observer( return ( -
+
({ + wrapper: css` + display: flex; + flex-wrap: nowrap; + gap: 8px; + z-index: 2; + `, +}); diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css index 52184f56..7369f56c 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.module.css @@ -76,7 +76,7 @@ } .details { - width: 250px; + width: 300px; padding: 5px 0; } @@ -121,7 +121,7 @@ } .second-column { - width: 102px; + width: 120px; } .icon { diff --git a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx index 72313d18..43cffa8a 100644 --- a/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx +++ b/grafana-plugin/src/containers/ScheduleSlot/ScheduleSlot.tsx @@ -11,7 +11,7 @@ import { Text } from 'components/Text/Text'; import { WorkingHours } from 'components/WorkingHours/WorkingHours'; import { getShiftName, SHIFT_SWAP_COLOR } from 'models/schedule/schedule.helpers'; import { Event, ShiftSwap } from 'models/schedule/schedule.types'; -import { getOffsetOfCurrentUser, getTzOffsetString } from 'models/timezone/timezone.helpers'; +import { getTzOffsetString } from 'models/timezone/timezone.helpers'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { useStore } from 'state/useStore'; @@ -32,8 +32,12 @@ interface ScheduleSlotProps { } const cx = cn.bind(styles); +const ONE_WEEK_IN_SECONDS = 7 * 24 * 60 * 60; export const ScheduleSlot: FC = observer((props) => { + const { + timezoneStore: { getDateInSelectedTimezone }, + } = useStore(); const { event, color, @@ -46,14 +50,12 @@ export const ScheduleSlot: FC = observer((props) => { showScheduleNameAsSlotTitle, } = props; - const start = dayjs(event.start); - const end = dayjs(event.end); + const start = getDateInSelectedTimezone(event.start); + const end = getDateInSelectedTimezone(event.end); - const duration = end.diff(start, 'seconds'); + const durationInSeconds = end.diff(start, 'seconds'); - const base = 60 * 60 * 24 * 7; - - const width = Math.max(duration / base, 0); + const width = Math.max(durationInSeconds / ONE_WEEK_IN_SECONDS, 0); const currentMoment = useMemo(() => dayjs(), []); @@ -90,7 +92,7 @@ export const ScheduleSlot: FC = observer((props) => { onShiftSwapClick={onShiftSwapClick} filters={filters} start={start} - duration={duration} + duration={durationInSeconds} color={color} currentMoment={currentMoment} showScheduleNameAsSlotTitle={showScheduleNameAsSlotTitle} @@ -411,7 +413,7 @@ const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => { User's local time
{currentMoment.tz(user?.timezone).format('DD MMM, HH:mm')} -
({getTzOffsetString(currentMoment.tz(user?.timezone))}) +
({user?.timezone}) Current timezone @@ -427,9 +429,9 @@ const ScheduleSlotDetails = observer((props: ScheduleSlotDetailsProps) => { This shift
- {dayjs(event.start).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')} + {dayjs(event.start).tz(user?.timezone).format('DD MMM, HH:mm')}
- {dayjs(event.end).utcOffset(getOffsetOfCurrentUser()).format('DD MMM, HH:mm')} + {dayjs(event.end).tz(user?.timezone).format('DD MMM, HH:mm')}
 
diff --git a/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx b/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx index be30eff6..b4aef832 100644 --- a/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx +++ b/grafana-plugin/src/containers/UserTimezoneSelect/UserTimezoneSelect.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useMemo, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { Select } from '@grafana/ui'; @@ -66,6 +66,8 @@ export const UserTimezoneSelect: FC = observer(({ sched ); }, [users, extraOptions]); + const selectedOption = options.find(({ value }) => value === store.timezoneStore.selectedTimezoneOffset); + const filterOption = useCallback((item: SelectableValue, searchQuery: string) => { const { data } = item; @@ -77,6 +79,12 @@ export const UserTimezoneSelect: FC = observer(({ sched }); }, []); + useEffect(() => { + if (selectedOption?.value) { + store.timezoneStore.setSelectedTimezoneOffset(selectedOption.value); + } + }, [options]); + const handleCreateOption = useCallback( (value: string) => { const matched = allTimezones.find((tz) => tz.toLowerCase().includes(value.toLowerCase())); @@ -110,7 +118,7 @@ export const UserTimezoneSelect: FC = observer(({ sched return (