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

Add oncall schedule helpers

parent a8a3056b
<script>
import { GlResizeObserverDirective } from '@gitlab/ui';
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 WeeksHeaderItem from './preset_weeks/weeks_header_item.vue';
export default {
PRESET_TYPES,
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
components: {
DaysHeaderItem,
WeeksHeaderItem,
......@@ -24,13 +29,30 @@ export default {
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>
<template>
<div class="timeline-section clearfix">
<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]" />
<weeks-header-item
v-for="(timeframeItem, index) in timeframe"
......
<script>
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 getTimelineWidthQuery from 'ee/oncall_schedules/graphql/queries/get_timeline_width.query.graphql';
import DaysScheduleShift from './days_schedule_shift.vue';
import { shiftsToRender } from './shift_utils';
import WeeksScheduleShift from './weeks_schedule_shift.vue';
......@@ -35,6 +36,7 @@ export default {
[PRESET_TYPES.DAYS]: DaysScheduleShift,
[PRESET_TYPES.WEEKS]: WeeksScheduleShift,
},
timelineWidth: 0,
};
},
apollo: {
......@@ -42,6 +44,10 @@ export default {
query: getShiftTimeUnitWidthQuery,
debounce: SHIFT_WIDTH_CALCULATION_DELAY,
},
timelineWidth: {
query: getTimelineWidthQuery,
debounce: SHIFT_WIDTH_CALCULATION_DELAY,
},
},
computed: {
rotationLength() {
......@@ -78,6 +84,7 @@ export default {
:timeframe="timeframe"
:shift-time-unit-width="shiftTimeUnitWidth"
:rotation-length="rotationLength"
:timeline-width="timelineWidth"
/>
</div>
</template>
......@@ -247,3 +247,93 @@ export const weekDisplayShiftWidth = (
const widthOffset = shiftStartDateOutOfRange && !shiftEndsAtMidnight ? 1 : 0;
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 @@
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 { getOverlapDateInPeriods, nDaysAfter } from '~/lib/utils/datetime_utility';
import { weekDisplayShiftLeft, weekDisplayShiftWidth } from './shift_utils';
import { weekDisplayShiftLeft, getPixelWidth } from './shift_utils';
export default {
components: {
......@@ -37,6 +37,10 @@ export default {
type: Object,
required: true,
},
timelineWidth: {
type: Number,
required: true,
},
},
computed: {
currentTimeFrameEnd() {
......@@ -51,6 +55,8 @@ export default {
shiftStartsAt,
timeframeItem,
presetType,
timelineWidth,
shift,
} = this;
return {
......@@ -63,11 +69,15 @@ export default {
timeframeItem,
presetType,
),
width: weekDisplayShiftWidth(
shiftUnitIsHour,
totalShiftRangeOverlap,
shiftStartDateOutOfRange,
shiftTimeUnitWidth,
width: Math.round(
getPixelWidth({
shift,
timelineWidth,
presetType,
shiftDLSOffset:
new Date(shift.startsAt).getTimezoneOffset() -
new Date(shift.endsAt).getTimezoneOffset(),
}),
),
};
},
......
......@@ -3,6 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/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);
......@@ -15,6 +16,13 @@ const resolvers = {
});
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';
import OnCallSchedulesWrapper from './components/oncall_schedules_wrapper.vue';
import apolloProvider from './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);
......@@ -20,6 +21,13 @@ export default () => {
},
});
apolloProvider.clients.defaultClient.cache.writeQuery({
query: getTimelineWidthQuery,
data: {
timelineWidth: 0,
},
});
return new Vue({
el,
apolloProvider,
......
......@@ -80,7 +80,7 @@ exports[`RotationsListSectionComponent when the timeframe includes today renders
<div>
<div
class="gl-absolute gl-h-7 gl-mt-3"
style="left: 0px; width: -2px;"
style="left: 0px; width: 0px;"
>
<div
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
</div>
<div
class="gl-absolute gl-h-7 gl-mt-3"
style="left: 0px; width: -2px;"
style="left: 0px; width: 0px;"
>
<div
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', () => {
schedule,
...props,
},
mocks: {
$apollo: { mutate: () => {} },
},
});
}
......
......@@ -6,6 +6,11 @@ import {
daysUntilEndOfTimeFrame,
weekDisplayShiftLeft,
weekDisplayShiftWidth,
getTotalTime,
getTimeOffset,
getDuration,
getPixelOffset,
getPixelWidth,
} from 'ee/oncall_schedules/components/schedule/components/shifts/components/shift_utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/constants';
......@@ -258,4 +263,67 @@ describe('~ee/oncall_schedules/components/schedule/components/shifts/components/
),
).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 = {
username: 'nora.schaden',
},
},
// 3.5 days
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;
......@@ -32,6 +33,7 @@ describe('ee/oncall_schedules/components/schedule/components/shifts/components/w
presetType: PRESET_TYPES.WEEKS,
shiftTimeUnitWidth: CELL_WIDTH,
rotationLength: { lengthUnit: 'DAYS' },
timelineWidth: CELL_WIDTH * 14,
...props,
},
});
......@@ -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', () => {
/**
* 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 } });
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
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', () => {
/**
* 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({
props: {
shift: {
...shift,
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 },
});
expect(findRotationAssignee().props('rotationAssigneeStyle')).toEqual({
left: '50px',
width: '198px',
width: '175px',
});
});
});
......@@ -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', () => {
/**
* 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({
left: '50px',
width: '48px',
width: '75px',
});
});
});
......@@ -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', () => {
/**
* 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({
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