Commit 70fdaed7 authored by Tristan Read's avatar Tristan Read Committed by Kushal Pandya

Add oncall schedule helpers

parent a8a3056b
<script> <script>
import { GlResizeObserverDirective } from '@gitlab/ui';
import { PRESET_TYPES } from 'ee/oncall_schedules/constants'; import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
import updateTimelineWidthMutation from 'ee/oncall_schedules/graphql/mutations/update_timeline_width.mutation.graphql';
import DaysHeaderItem from './preset_days/days_header_item.vue'; import DaysHeaderItem from './preset_days/days_header_item.vue';
import WeeksHeaderItem from './preset_weeks/weeks_header_item.vue'; import WeeksHeaderItem from './preset_weeks/weeks_header_item.vue';
export default { export default {
PRESET_TYPES, PRESET_TYPES,
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
components: { components: {
DaysHeaderItem, DaysHeaderItem,
WeeksHeaderItem, WeeksHeaderItem,
...@@ -24,13 +29,30 @@ export default { ...@@ -24,13 +29,30 @@ export default {
return this.presetType === this.$options.PRESET_TYPES.DAYS; return this.presetType === this.$options.PRESET_TYPES.DAYS;
}, },
}, },
mounted() {
this.updateShiftStyles();
},
methods: {
updateShiftStyles() {
this.$apollo.mutate({
mutation: updateTimelineWidthMutation,
variables: {
timelineWidth: this.$refs.timelineHeaderWrapper.offsetWidth,
},
});
},
},
}; };
</script> </script>
<template> <template>
<div class="timeline-section clearfix"> <div class="timeline-section clearfix">
<span class="timeline-header-blank"></span> <span class="timeline-header-blank"></span>
<div> <div
ref="timelineHeaderWrapper"
v-gl-resize-observer="updateShiftStyles"
data-testid="timeline-header-wrapper"
>
<days-header-item v-if="presetIsDay" :timeframe-item="timeframe[0]" /> <days-header-item v-if="presetIsDay" :timeframe-item="timeframe[0]" />
<weeks-header-item <weeks-header-item
v-for="(timeframeItem, index) in timeframe" v-for="(timeframeItem, index) in timeframe"
......
<script> <script>
import { PRESET_TYPES, SHIFT_WIDTH_CALCULATION_DELAY } from 'ee/oncall_schedules/constants'; import { PRESET_TYPES, SHIFT_WIDTH_CALCULATION_DELAY } from 'ee/oncall_schedules/constants';
import getShiftTimeUnitWidthQuery from 'ee/oncall_schedules/graphql/queries/get_shift_time_unit_width.query.graphql'; import getShiftTimeUnitWidthQuery from 'ee/oncall_schedules/graphql/queries/get_shift_time_unit_width.query.graphql';
import getTimelineWidthQuery from 'ee/oncall_schedules/graphql/queries/get_timeline_width.query.graphql';
import DaysScheduleShift from './days_schedule_shift.vue'; import DaysScheduleShift from './days_schedule_shift.vue';
import { shiftsToRender } from './shift_utils'; import { shiftsToRender } from './shift_utils';
import WeeksScheduleShift from './weeks_schedule_shift.vue'; import WeeksScheduleShift from './weeks_schedule_shift.vue';
...@@ -35,6 +36,7 @@ export default { ...@@ -35,6 +36,7 @@ export default {
[PRESET_TYPES.DAYS]: DaysScheduleShift, [PRESET_TYPES.DAYS]: DaysScheduleShift,
[PRESET_TYPES.WEEKS]: WeeksScheduleShift, [PRESET_TYPES.WEEKS]: WeeksScheduleShift,
}, },
timelineWidth: 0,
}; };
}, },
apollo: { apollo: {
...@@ -42,6 +44,10 @@ export default { ...@@ -42,6 +44,10 @@ export default {
query: getShiftTimeUnitWidthQuery, query: getShiftTimeUnitWidthQuery,
debounce: SHIFT_WIDTH_CALCULATION_DELAY, debounce: SHIFT_WIDTH_CALCULATION_DELAY,
}, },
timelineWidth: {
query: getTimelineWidthQuery,
debounce: SHIFT_WIDTH_CALCULATION_DELAY,
},
}, },
computed: { computed: {
rotationLength() { rotationLength() {
...@@ -78,6 +84,7 @@ export default { ...@@ -78,6 +84,7 @@ export default {
:timeframe="timeframe" :timeframe="timeframe"
:shift-time-unit-width="shiftTimeUnitWidth" :shift-time-unit-width="shiftTimeUnitWidth"
:rotation-length="rotationLength" :rotation-length="rotationLength"
:timeline-width="timelineWidth"
/> />
</div> </div>
</template> </template>
...@@ -247,3 +247,93 @@ export const weekDisplayShiftWidth = ( ...@@ -247,3 +247,93 @@ export const weekDisplayShiftWidth = (
const widthOffset = shiftStartDateOutOfRange && !shiftEndsAtMidnight ? 1 : 0; const widthOffset = shiftStartDateOutOfRange && !shiftEndsAtMidnight ? 1 : 0;
return shiftTimeUnitWidth * (shiftRangeOverlap.daysOverlap - widthOffset) - ASSIGNEE_SPACER; return shiftTimeUnitWidth * (shiftRangeOverlap.daysOverlap - widthOffset) - ASSIGNEE_SPACER;
}; };
// New utils, unused for now. Added as part of the
// https://gitlab.com/gitlab-org/gitlab/-/issues/324608 merge train.
/**
* Returns a specified time value as milliseconds.
*
* @param {Object} input data
* @return {Number} the time value in milliseconds
*/
export const milliseconds = ({ h = 0, m = 0, s = 0 }) => (h * 60 * 60 + m * 60 + s) * 1000;
/**
* Returns the start date of a shift in milliseconds
*
* @param {IncidentManagementOncallShift} shift
* @return {Number} start date in milliseconds
*/
export const getAbsoluteStartDate = ({ startsAt }) => {
return new Date(startsAt).getTime();
};
/**
* Returns the end date of a shift in milliseconds
*
* @param {IncidentManagementOncallShift} shift
* @return {Number} end date in milliseconds
*/
export const getAbsoluteEndDate = ({ endsAt }) => {
return new Date(endsAt).getTime();
};
/**
* Returns the length of the timeline in milliseconds
*
* @param {Enum} presetType
* @return {Number} timeline length in milliseconds
*/
export const getTotalTime = (presetType) => {
const MS_PER_DAY = milliseconds({ h: 24 });
return presetType === PRESET_TYPES.DAYS ? MS_PER_DAY : MS_PER_DAY * 14; // Either 1 day or two weeks
};
/**
* Returns the time difference between the beginning of the timeline and the beginning of a shift
*
* @param {Date} timelineStartDate
* @param {IncidentManagementOncallShift} shift
* @return {Number} offset in milliseconds
*/
export const getTimeOffset = (timelineStartDate, shift) => {
return getAbsoluteStartDate(shift) - timelineStartDate.getTime();
};
/**
* Returns the duration of a shift in milliseconds
*
* @param {IncidentManagementOncallShift} shift
* @return {Number} duration in milliseconds
*/
export const getDuration = (shift) => {
return getAbsoluteEndDate(shift) - getAbsoluteStartDate(shift);
};
/**
* Returns the pixel distance between the beginning of the timeline and the beginning of a shift
*
* @param {Object} timeframe, shift, timelineWidth, presetType
* @return {Number} distance in pixels
*/
export const getPixelOffset = ({ timeframe, shift, timelineWidth, presetType }) => {
const totalTime = getTotalTime(presetType);
const timeOffset = getTimeOffset(timeframe[0], shift);
// offset (px) = total width (px) * shift time (ms) / total time (ms)
return (timelineWidth * timeOffset) / totalTime;
};
/**
* Returns the width of a shift in pixels
*
* @param {Object} shift, timelineWidth, presetType, shiftDLSOffset
* @return {Number} width in pixels
*/
export const getPixelWidth = ({ shift, timelineWidth, presetType, shiftDLSOffset }) => {
const totalTime = getTotalTime(presetType);
const durationMillis = getDuration(shift);
const DLS = milliseconds({ m: shiftDLSOffset });
// shift width (px) = shift time (ms) * total width (px) / total time (ms)
return ((durationMillis + DLS) * timelineWidth) / totalTime;
};
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue'; import RotationAssignee from 'ee/oncall_schedules/components/rotations/components/rotation_assignee.vue';
import { DAYS_IN_WEEK, HOURS_IN_DAY } from 'ee/oncall_schedules/constants'; import { DAYS_IN_WEEK, HOURS_IN_DAY } from 'ee/oncall_schedules/constants';
import { getOverlapDateInPeriods, nDaysAfter } from '~/lib/utils/datetime_utility'; import { getOverlapDateInPeriods, nDaysAfter } from '~/lib/utils/datetime_utility';
import { weekDisplayShiftLeft, weekDisplayShiftWidth } from './shift_utils'; import { weekDisplayShiftLeft, getPixelWidth } from './shift_utils';
export default { export default {
components: { components: {
...@@ -37,6 +37,10 @@ export default { ...@@ -37,6 +37,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
timelineWidth: {
type: Number,
required: true,
},
}, },
computed: { computed: {
currentTimeFrameEnd() { currentTimeFrameEnd() {
...@@ -51,6 +55,8 @@ export default { ...@@ -51,6 +55,8 @@ export default {
shiftStartsAt, shiftStartsAt,
timeframeItem, timeframeItem,
presetType, presetType,
timelineWidth,
shift,
} = this; } = this;
return { return {
...@@ -63,11 +69,15 @@ export default { ...@@ -63,11 +69,15 @@ export default {
timeframeItem, timeframeItem,
presetType, presetType,
), ),
width: weekDisplayShiftWidth( width: Math.round(
shiftUnitIsHour, getPixelWidth({
totalShiftRangeOverlap, shift,
shiftStartDateOutOfRange, timelineWidth,
shiftTimeUnitWidth, presetType,
shiftDLSOffset:
new Date(shift.startsAt).getTimezoneOffset() -
new Date(shift.endsAt).getTimezoneOffset(),
}),
), ),
}; };
}, },
......
...@@ -3,6 +3,7 @@ import Vue from 'vue'; ...@@ -3,6 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import getShiftTimeUnitWidthQuery from './graphql/queries/get_shift_time_unit_width.query.graphql'; import getShiftTimeUnitWidthQuery from './graphql/queries/get_shift_time_unit_width.query.graphql';
import getTimelineWidthQuery from './graphql/queries/get_timeline_width.query.graphql';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -15,6 +16,13 @@ const resolvers = { ...@@ -15,6 +16,13 @@ const resolvers = {
}); });
cache.writeQuery({ query: getShiftTimeUnitWidthQuery, data }); cache.writeQuery({ query: getShiftTimeUnitWidthQuery, data });
}, },
updateTimelineWidth: (_, { timelineWidth = 0 }, { cache }) => {
const sourceData = cache.readQuery({ query: getTimelineWidthQuery });
const data = produce(sourceData, (draftData) => {
draftData.timelineWidth = timelineWidth;
});
cache.writeQuery({ query: getTimelineWidthQuery, data });
},
}, },
}; };
......
mutation updateTimelineWidth($timelineWidth: Int) {
updateTimelineWidth(timelineWidth: $timelineWidth) @client
}
...@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo'; ...@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo';
import OnCallSchedulesWrapper from './components/oncall_schedules_wrapper.vue'; import OnCallSchedulesWrapper from './components/oncall_schedules_wrapper.vue';
import apolloProvider from './graphql'; import apolloProvider from './graphql';
import getShiftTimeUnitWidthQuery from './graphql/queries/get_shift_time_unit_width.query.graphql'; import getShiftTimeUnitWidthQuery from './graphql/queries/get_shift_time_unit_width.query.graphql';
import getTimelineWidthQuery from './graphql/queries/get_timeline_width.query.graphql';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -20,6 +21,13 @@ export default () => { ...@@ -20,6 +21,13 @@ export default () => {
}, },
}); });
apolloProvider.clients.defaultClient.cache.writeQuery({
query: getTimelineWidthQuery,
data: {
timelineWidth: 0,
},
});
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
......
...@@ -80,7 +80,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -80,7 +80,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
<div> <div>
<div <div
class="gl-absolute gl-h-7 gl-mt-3" class="gl-absolute gl-h-7 gl-mt-3"
style="left: 0px; width: -2px;" style="left: 0px; width: 0px;"
> >
<div <div
class="gl-h-6 gl-bg-data-viz-blue-500 gl-display-flex gl-justify-content-center gl-align-items-center" class="gl-h-6 gl-bg-data-viz-blue-500 gl-display-flex gl-justify-content-center gl-align-items-center"
...@@ -120,7 +120,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders ...@@ -120,7 +120,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
</div> </div>
<div <div
class="gl-absolute gl-h-7 gl-mt-3" class="gl-absolute gl-h-7 gl-mt-3"
style="left: 0px; width: -2px;" style="left: 0px; width: 0px;"
> >
<div <div
class="gl-h-6 gl-bg-data-viz-orange-500 gl-display-flex gl-justify-content-center gl-align-items-center" class="gl-h-6 gl-bg-data-viz-orange-500 gl-display-flex gl-justify-content-center gl-align-items-center"
......
...@@ -21,6 +21,9 @@ describe('TimelineSectionComponent', () => { ...@@ -21,6 +21,9 @@ describe('TimelineSectionComponent', () => {
schedule, schedule,
...props, ...props,
}, },
mocks: {
$apollo: { mutate: () => {} },
},
}); });
} }
......
...@@ -6,6 +6,11 @@ import { ...@@ -6,6 +6,11 @@ import {
daysUntilEndOfTimeFrame, daysUntilEndOfTimeFrame,
weekDisplayShiftLeft, weekDisplayShiftLeft,
weekDisplayShiftWidth, weekDisplayShiftWidth,
getTotalTime,
getTimeOffset,
getDuration,
getPixelOffset,
getPixelWidth,
} from 'ee/oncall_schedules/components/schedule/components/shifts/components/shift_utils'; } from 'ee/oncall_schedules/components/schedule/components/shifts/components/shift_utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/constants'; import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
...@@ -258,4 +263,67 @@ describe('~ee/oncall_schedules/components/schedule/components/shifts/components/ ...@@ -258,4 +263,67 @@ describe('~ee/oncall_schedules/components/schedule/components/shifts/components/
), ),
).toBe(98); ).toBe(98);
}); });
describe('shift utils', () => {
// An 8 hour shift
const shift = {
startsAt: '2021-01-13T12:00:00.000Z',
endsAt: '2021-01-13T20:00:00.000Z',
participant: null,
};
const ONE_HOUR = 60 * 60 * 1000;
const EIGHT_HOURS = 8 * ONE_HOUR;
const TWELVE_HOURS = 12 * ONE_HOUR;
const ONE_DAY = 2 * TWELVE_HOURS;
const TWO_WEEKS = 14 * ONE_DAY;
describe('getTotalTime', () => {
it('returns the correct length for the days view', () => {
expect(getTotalTime(PRESET_TYPES.DAYS)).toBe(ONE_DAY);
});
it('returns the correct length for the 2 week view', () => {
expect(getTotalTime(PRESET_TYPES.WEEKS)).toBe(TWO_WEEKS);
});
});
describe('getTimeOffset', () => {
it('calculates the correct time offest', () => {
const timelineStartDate = new Date('2021-01-13T00:00:00.000Z');
const offset = getTimeOffset(timelineStartDate, shift);
expect(offset).toBe(TWELVE_HOURS);
});
});
describe('getDuration', () => {
it('calculates the correct duration', () => {
const duration = getDuration(shift);
expect(duration).toBe(EIGHT_HOURS); // 8 hours
});
});
describe('getPixelOffset', () => {
it('calculates the correct pixel offest', () => {
const timeframe = [
new Date('2021-01-13T00:00:00.000Z'),
new Date('2021-01-14T00:00:00.000Z'),
];
const timelineWidth = 1000;
const presetType = PRESET_TYPES.DAYS;
const pixelOffset = getPixelOffset({ timeframe, shift, timelineWidth, presetType });
expect(pixelOffset).toBe(500); // midday = half the total width
});
});
describe('getPixelWidth', () => {
it('calculates the correct pixel width', () => {
const timelineWidth = 1200; // 50 pixels per hour
const presetType = PRESET_TYPES.DAYS;
const shiftDLSOffset = 60; // one hour
const pixelWidth = getPixelWidth({ shift, timelineWidth, presetType, shiftDLSOffset });
expect(pixelWidth).toBe(450); // 7 hrs
});
});
});
}); });
...@@ -11,8 +11,9 @@ const shift = { ...@@ -11,8 +11,9 @@ const shift = {
username: 'nora.schaden', username: 'nora.schaden',
}, },
}, },
// 3.5 days
startsAt: '2021-01-12T10:04:56.333Z', startsAt: '2021-01-12T10:04:56.333Z',
endsAt: '2021-01-15T10:04:56.333Z', endsAt: '2021-01-15T22:04:56.333Z',
}; };
const CELL_WIDTH = 50; const CELL_WIDTH = 50;
...@@ -32,6 +33,7 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w ...@@ -32,6 +33,7 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
presetType: PRESET_TYPES.WEEKS, presetType: PRESET_TYPES.WEEKS,
shiftTimeUnitWidth: CELL_WIDTH, shiftTimeUnitWidth: CELL_WIDTH,
rotationLength: { lengthUnit: 'DAYS' }, rotationLength: { lengthUnit: 'DAYS' },
timelineWidth: CELL_WIDTH * 14,
...props, ...props,
}, },
}); });
...@@ -55,33 +57,33 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w ...@@ -55,33 +57,33 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
it('calculates the correct rotation assignee styles when the shift starts at the beginning of the time-frame cell', () => { it('calculates the correct rotation assignee styles when the shift starts at the beginning of the time-frame cell', () => {
/** /**
* Where left should be 0px i.e. beginning of time-frame cell * Where left should be 0px i.e. beginning of time-frame cell
* and width should be overlapping days * CELL_WIDTH - ASSIGNEE_SPACER((3 * 50) - 2) * and width should be absolute pixel width (3.5 * CELL_WIDTH)
*/ */
createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } }); createComponent({ data: { shiftTimeUnitWidth: CELL_WIDTH } });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({ expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '0px', left: '0px',
width: '98px', width: '175px',
}); });
}); });
it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => { it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => {
/** /**
* Where left should be 52x i.e. ((DAYS_IN_WEEK - (timeframeEndsAt - overlapStartDate)) * CELL_WIDTH)(((7 - (20 - 14)) * 50)) * Where left should be 52x i.e. ((DAYS_IN_WEEK - (timeframeEndsAt - overlapStartDate)) * CELL_WIDTH)(((7 - (20 - 14)) * 50))
* and width should be overlapping (days * CELL_WIDTH) - ASSIGNEE_SPACER((4 * 50) - 2) * and width should be absolute pixel width (3.5 * CELL_WIDTH)
*/ */
createComponent({ createComponent({
props: { props: {
shift: { shift: {
...shift, ...shift,
startsAt: '2021-01-14T10:04:56.333Z', startsAt: '2021-01-14T10:04:56.333Z',
endsAt: '2021-01-18T10:04:56.333Z', endsAt: '2021-01-17T22:04:56.333Z',
}, },
}, },
data: { shiftTimeUnitWidth: CELL_WIDTH }, data: { shiftTimeUnitWidth: CELL_WIDTH },
}); });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({ expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '50px', left: '50px',
width: '198px', width: '175px',
}); });
}); });
}); });
...@@ -101,11 +103,11 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w ...@@ -101,11 +103,11 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => { it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => {
/** /**
* Where left should be ((DAYS_IN_WEEK - (timeframeEndsAt - overlapStartDate)) * CELL_WIDTH)(((7 - (20 - 14)) * 50)) * Where left should be ((DAYS_IN_WEEK - (timeframeEndsAt - overlapStartDate)) * CELL_WIDTH)(((7 - (20 - 14)) * 50))
* and width should be (overlappingDays * CELL_WIDTH) - ASSIGNEE_SPACER((1 * 50) - 2) * and width should be absolute pixel width (1.5 * CELL_WIDTH)
*/ */
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({ expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '50px', left: '50px',
width: '48px', width: '75px',
}); });
}); });
}); });
...@@ -132,11 +134,11 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w ...@@ -132,11 +134,11 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => { it('calculates the correct rotation assignee styles when the shift does not start at the beginning of the time-frame cell', () => {
/** /**
* Where left should be 70px i.e. ((CELL_WIDTH / HOURS_IN_DAY) * overlapStartDate + dayOffSet)(50 / 24 * 10) + 50; * Where left should be 70px i.e. ((CELL_WIDTH / HOURS_IN_DAY) * overlapStartDate + dayOffSet)(50 / 24 * 10) + 50;
* and width should be 2px ((CELL_WIDTH / HOURS_IN_DAY) * hoursOverlap - ASSIGNEE_SPACER) (((50 / 24) * 2) - 2) * and width should be the correct fraction of a day: (hours / 24) * CELL_WIDTH
*/ */
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({ expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '70px', left: '70px',
width: '2px', width: '4px',
}); });
}); });
}); });
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment