Commit c4e9fe01 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'mw-productivity-analytics-scatterplot' into 'master'

Productivity analytics: Add scatterplot

Closes #13412

See merge request gitlab-org/gitlab!15569
parents d9117a45 2b0f6ddb
...@@ -547,3 +547,13 @@ export const calculateRemainingMilliseconds = endDate => { ...@@ -547,3 +547,13 @@ export const calculateRemainingMilliseconds = endDate => {
const remainingMilliseconds = new Date(endDate).getTime() - Date.now(); const remainingMilliseconds = new Date(endDate).getTime() - Date.now();
return Math.max(remainingMilliseconds, 0); return Math.max(remainingMilliseconds, 0);
}; };
/**
* Subtracts a given number of days from a given date and returns the new date.
*
* @param {Date} date the date that we will substract days from
* @param {number} daysInPast number of days that are subtracted from a given date
* @returns {String} Date string in ISO format
*/
export const getDateInPast = (date, daysInPast) =>
new Date(date.setTime(date.getTime() - daysInPast * 24 * 60 * 60 * 1000)).toISOString();
...@@ -106,3 +106,14 @@ export const sum = (a = 0, b = 0) => a + b; ...@@ -106,3 +106,14 @@ export const sum = (a = 0, b = 0) => a + b;
* @param {Int} number * @param {Int} number
*/ */
export const isOdd = (number = 0) => number % 2; export const isOdd = (number = 0) => number % 2;
/**
* Computes the median for a given array.
* @param {Array} arr An array of numbers
* @returns {Number} The median of the given array
*/
export const median = arr => {
const middle = Math.floor(arr.length / 2);
const sorted = arr.sort((a, b) => a - b);
return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
};
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
import { GlColumnChart } from '@gitlab/ui/dist/charts'; import { GlColumnChart } from '@gitlab/ui/dist/charts';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import MetricChart from './metric_chart.vue'; import MetricChart from './metric_chart.vue';
import Scatterplot from '../../shared/components/scatterplot.vue';
import MergeRequestTable from './mr_table.vue'; import MergeRequestTable from './mr_table.vue';
import { chartKeys } from '../constants'; import { chartKeys } from '../constants';
...@@ -24,6 +25,7 @@ export default { ...@@ -24,6 +25,7 @@ export default {
GlButton, GlButton,
Icon, Icon,
MetricChart, MetricChart,
Scatterplot,
MergeRequestTable, MergeRequestTable,
}, },
directives: { directives: {
...@@ -55,8 +57,10 @@ export default { ...@@ -55,8 +57,10 @@ export default {
...mapGetters('charts', [ ...mapGetters('charts', [
'chartLoading', 'chartLoading',
'chartHasData', 'chartHasData',
'getChartData', 'getColumnChartData',
'getColumnChartDatazoomOption', 'getColumnChartDatazoomOption',
'getScatterPlotMainData',
'getScatterPlotMedianData',
'getMetricDropdownLabel', 'getMetricDropdownLabel',
'getSelectedMetric', 'getSelectedMetric',
'hasNoAccessError', 'hasNoAccessError',
...@@ -141,19 +145,19 @@ export default { ...@@ -141,19 +145,19 @@ export default {
" "
/> />
<template v-if="showAppContent"> <template v-if="showAppContent">
<h4>{{ __('Merge Requests') }}</h4> <h4>{{ s__('ProductivityAnalytics|Merge Requests') }}</h4>
<metric-chart <metric-chart
ref="mainChart" ref="mainChart"
class="mb-4" class="mb-4"
:title="__('Time to merge')" :title="s__('ProductivityAnalytics|Time to merge')"
:description=" :description="
__('You can filter by \'days to merge\' by clicking on the columns in the chart.') __('You can filter by \'days to merge\' by clicking on the columns in the chart.')
" "
:is-loading="chartLoading(chartKeys.main)" :is-loading="chartLoading(chartKeys.main)"
:chart-data="getChartData(chartKeys.main)" :chart-data="getColumnChartData(chartKeys.main)"
> >
<gl-column-chart <gl-column-chart
:data="{ full: getChartData(chartKeys.main) }" :data="{ full: getColumnChartData(chartKeys.main) }"
:option="getColumnChartOption(chartKeys.main)" :option="getColumnChartOption(chartKeys.main)"
:y-axis-title="__('Merge requests')" :y-axis-title="__('Merge requests')"
:x-axis-title="__('Days')" :x-axis-title="__('Days')"
...@@ -176,17 +180,17 @@ export default { ...@@ -176,17 +180,17 @@ export default {
:is-loading="chartLoading(chartKeys.timeBasedHistogram)" :is-loading="chartLoading(chartKeys.timeBasedHistogram)"
:metric-types="getMetricTypes(chartKeys.timeBasedHistogram)" :metric-types="getMetricTypes(chartKeys.timeBasedHistogram)"
:selected-metric="getSelectedMetric(chartKeys.timeBasedHistogram)" :selected-metric="getSelectedMetric(chartKeys.timeBasedHistogram)"
:chart-data="getChartData(chartKeys.timeBasedHistogram)" :chart-data="getColumnChartData(chartKeys.timeBasedHistogram)"
@metricTypeChange=" @metricTypeChange="
metric => metric =>
setMetricType({ metricType: metric, chartKey: chartKeys.timeBasedHistogram }) setMetricType({ metricType: metric, chartKey: chartKeys.timeBasedHistogram })
" "
> >
<gl-column-chart <gl-column-chart
:data="{ full: getChartData(chartKeys.timeBasedHistogram) }" :data="{ full: getColumnChartData(chartKeys.timeBasedHistogram) }"
:option="getColumnChartOption(chartKeys.timeBasedHistogram)" :option="getColumnChartOption(chartKeys.timeBasedHistogram)"
:y-axis-title="__('Merge requests')" :y-axis-title="s__('ProductivityAnalytics|Merge requests')"
:x-axis-title="__('Hours')" :x-axis-title="s__('ProductivityAnalytics|Hours')"
x-axis-type="category" x-axis-type="category"
/> />
</metric-chart> </metric-chart>
...@@ -202,26 +206,46 @@ export default { ...@@ -202,26 +206,46 @@ export default {
:is-loading="chartLoading(chartKeys.commitBasedHistogram)" :is-loading="chartLoading(chartKeys.commitBasedHistogram)"
:metric-types="getMetricTypes(chartKeys.commitBasedHistogram)" :metric-types="getMetricTypes(chartKeys.commitBasedHistogram)"
:selected-metric="getSelectedMetric(chartKeys.commitBasedHistogram)" :selected-metric="getSelectedMetric(chartKeys.commitBasedHistogram)"
:chart-data="getChartData(chartKeys.commitBasedHistogram)" :chart-data="getColumnChartData(chartKeys.commitBasedHistogram)"
@metricTypeChange=" @metricTypeChange="
metric => metric =>
setMetricType({ metricType: metric, chartKey: chartKeys.commitBasedHistogram }) setMetricType({ metricType: metric, chartKey: chartKeys.commitBasedHistogram })
" "
> >
<gl-column-chart <gl-column-chart
:data="{ full: getChartData(chartKeys.commitBasedHistogram) }" :data="{ full: getColumnChartData(chartKeys.commitBasedHistogram) }"
:option="getColumnChartOption(chartKeys.commitBasedHistogram)" :option="getColumnChartOption(chartKeys.commitBasedHistogram)"
:y-axis-title="__('Merge requests')" :y-axis-title="s__('ProductivityAanalytics|Merge requests')"
:x-axis-title="getMetricDropdownLabel(chartKeys.commitBasedHistogram)" :x-axis-title="getMetricDropdownLabel(chartKeys.commitBasedHistogram)"
x-axis-type="category" x-axis-type="category"
/> />
</metric-chart> </metric-chart>
</div> </div>
<metric-chart
ref="scatterplot"
class="mb-4"
:title="s__('ProductivityAnalytics|Trendline')"
:is-loading="chartLoading(chartKeys.scatterplot)"
:metric-types="getMetricTypes(chartKeys.scatterplot)"
:chart-data="getScatterPlotMainData"
:selected-metric="getSelectedMetric(chartKeys.scatterplot)"
@metricTypeChange="
metric => setMetricType({ metricType: metric, chartKey: chartKeys.scatterplot })
"
>
<scatterplot
:x-axis-title="s__('ProductivityAnalytics|Merge date')"
:y-axis-title="s__('ProductivityAnalytics|Days')"
:scatter-data="getScatterPlotMainData"
:median-line-data="getScatterPlotMedianData"
/>
</metric-chart>
<div <div
class="js-mr-table-sort d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2" class="js-mr-table-sort d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2"
> >
<h5>{{ __('List') }}</h5> <h5>{{ s__('ProductivityAnalytics|List') }}</h5>
<div <div
v-if="showMergeRequestTable" v-if="showMergeRequestTable"
class="d-flex flex-column flex-md-row align-items-md-center" class="d-flex flex-column flex-md-row align-items-md-center"
...@@ -261,7 +285,6 @@ export default { ...@@ -261,7 +285,6 @@ export default {
</div> </div>
<div class="js-mr-table"> <div class="js-mr-table">
<div ref="foo"></div>
<gl-loading-icon v-if="isLoadingTable" size="md" class="my-4 py-4" /> <gl-loading-icon v-if="isLoadingTable" size="md" class="my-4 py-4" />
<merge-request-table <merge-request-table
v-if="showMergeRequestTable" v-if="showMergeRequestTable"
......
...@@ -13,35 +13,40 @@ export const chartTypes = { ...@@ -13,35 +13,40 @@ export const chartTypes = {
}; };
export const metricTypes = [ export const metricTypes = [
{
key: 'days_to_merge',
label: __('Days to merge'),
charts: [chartKeys.scatterplot],
},
{ {
key: 'time_to_first_comment', key: 'time_to_first_comment',
label: __('Time from first commit until first comment'), label: __('Time from first commit until first comment'),
chart: chartKeys.timeBasedHistogram, charts: [chartKeys.timeBasedHistogram, chartKeys.scatterplot],
}, },
{ {
key: 'time_to_last_commit', key: 'time_to_last_commit',
label: __('Time from first comment to last commit'), label: __('Time from first comment to last commit'),
chart: chartKeys.timeBasedHistogram, charts: [chartKeys.timeBasedHistogram, chartKeys.scatterplot],
}, },
{ {
key: 'time_to_merge', key: 'time_to_merge',
label: __('Time from last commit to merge'), label: __('Time from last commit to merge'),
chart: chartKeys.timeBasedHistogram, charts: [chartKeys.timeBasedHistogram, chartKeys.scatterplot],
}, },
{ {
key: 'commits_count', key: 'commits_count',
label: __('Number of commits per MR'), label: __('Number of commits per MR'),
chart: chartKeys.commitBasedHistogram, charts: [chartKeys.commitBasedHistogram, chartKeys.scatterplot],
}, },
{ {
key: 'loc_per_commit', key: 'loc_per_commit',
label: __('Number of LOCs per commit'), label: __('Number of LOCs per commit'),
chart: chartKeys.commitBasedHistogram, charts: [chartKeys.commitBasedHistogram, chartKeys.scatterplot],
}, },
{ {
key: 'files_touched', key: 'files_touched',
label: __('Number of files touched'), label: __('Number of files touched'),
chart: chartKeys.commitBasedHistogram, charts: [chartKeys.commitBasedHistogram, chartKeys.scatterplot],
}, },
]; ];
...@@ -64,7 +69,6 @@ export const daysToMergeMetric = { ...@@ -64,7 +69,6 @@ export const daysToMergeMetric = {
}; };
export const defaultMaxColumnChartItemsPerPage = 20; export const defaultMaxColumnChartItemsPerPage = 20;
export const maxColumnChartItemsPerPage = { export const maxColumnChartItemsPerPage = {
[chartKeys.main]: 40, [chartKeys.main]: 40,
}; };
...@@ -75,10 +79,6 @@ export const dataZoomOptions = [ ...@@ -75,10 +79,6 @@ export const dataZoomOptions = [
bottom: 10, bottom: 10,
start: 0, start: 0,
}, },
{
type: 'inside',
start: 0,
},
]; ];
/** /**
...@@ -86,6 +86,10 @@ export const dataZoomOptions = [ ...@@ -86,6 +86,10 @@ export const dataZoomOptions = [
*/ */
export const columnHighlightStyle = { color: '#418cd8', opacity: 0.8 }; export const columnHighlightStyle = { color: '#418cd8', opacity: 0.8 };
// The number of days which will be to the state's daysInPast
// This is required to query historical data from the API to draw a 30 days rolling median line
export const scatterPlotAddonQueryDays = 30;
export const accessLevelReporter = 20; export const accessLevelReporter = 20;
export const projectsPerPage = 50; export const projectsPerPage = 50;
export const getMetricTypes = state => chartKey => export const getMetricTypes = state => chartKey =>
state.metricTypes.filter(m => m.chart === chartKey); state.metricTypes.filter(m => m.charts.indexOf(chartKey) !== -1);
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -50,11 +50,8 @@ export const setMetricType = ({ commit, dispatch }, { chartKey, metricType }) => ...@@ -50,11 +50,8 @@ export const setMetricType = ({ commit, dispatch }, { chartKey, metricType }) =>
export const chartItemClicked = ({ commit, dispatch }, { chartKey, item }) => { export const chartItemClicked = ({ commit, dispatch }, { chartKey, item }) => {
commit(types.UPDATE_SELECTED_CHART_ITEMS, { chartKey, item }); commit(types.UPDATE_SELECTED_CHART_ITEMS, { chartKey, item });
// update histograms // update secondary charts
dispatch('fetchChartData', chartKeys.timeBasedHistogram); dispatch('fetchSecondaryChartData');
dispatch('fetchChartData', chartKeys.commitBasedHistogram);
// TODO: update scatterplot
// update table // update table
dispatch('table/fetchMergeRequests', null, { root: true }); dispatch('table/fetchMergeRequests', null, { root: true });
......
import _ from 'underscore'; import _ from 'underscore';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { import {
chartKeys, chartKeys,
metricTypes, metricTypes,
...@@ -7,7 +8,9 @@ import { ...@@ -7,7 +8,9 @@ import {
defaultMaxColumnChartItemsPerPage, defaultMaxColumnChartItemsPerPage,
maxColumnChartItemsPerPage, maxColumnChartItemsPerPage,
dataZoomOptions, dataZoomOptions,
scatterPlotAddonQueryDays,
} from '../../../constants'; } from '../../../constants';
import { getScatterPlotData, getMedianLineData } from '../../../utils';
export const chartLoading = state => chartKey => state.charts[chartKey].isLoading; export const chartLoading = state => chartKey => state.charts[chartKey].isLoading;
...@@ -30,7 +33,7 @@ export const chartLoading = state => chartKey => state.charts[chartKey].isLoadin ...@@ -30,7 +33,7 @@ export const chartLoading = state => chartKey => state.charts[chartKey].isLoadin
* the itemStyle will be set accordingly in order to highlight the relevant bar. * the itemStyle will be set accordingly in order to highlight the relevant bar.
* *
*/ */
export const getChartData = state => chartKey => { export const getColumnChartData = state => chartKey => {
const dataWithSelected = Object.keys(state.charts[chartKey].data).map(key => { const dataWithSelected = Object.keys(state.charts[chartKey].data).map(key => {
const dataArr = [key, state.charts[chartKey].data[key]]; const dataArr = [key, state.charts[chartKey].data[key]];
let itemStyle = {}; let itemStyle = {};
...@@ -49,6 +52,44 @@ export const getChartData = state => chartKey => { ...@@ -49,6 +52,44 @@ export const getChartData = state => chartKey => {
}; };
export const chartHasData = state => chartKey => !_.isEmpty(state.charts[chartKey].data); export const chartHasData = state => chartKey => !_.isEmpty(state.charts[chartKey].data);
/**
* Creates a series array of main data for the scatterplot chart.
*
* Takes an object of the form
* {
* "1": { "metric": 138", merged_at": "2019-07-09T14:58:07.756Z" },
* "2": { "metric": 139, "merged_at": "2019-07-10T11:13:23.557Z" },
* "3": { "metric": 24, "merged_at": "2019-07-01T07:06:23.193Z" }
* }
*
* and creates the following structure:
*
* [
* ["2019-07-01T07:06:23.193Z", 24],
* ["2019-07-09T14:58:07.756Z", 138],
* ["2019-07-10T11:13:23.557Z", 139],
* ]
*
* It eliminates items which were merged before today minus the selected daysInPast.
*/
export const getScatterPlotMainData = (state, getters, rootState) => {
const { data } = state.charts.scatterplot;
const dateInPast = getDateInPast(new Date(), rootState.filters.daysInPast);
return getScatterPlotData(data, dateInPast);
};
/**
* Creates a series array of median data for the scatterplot chart.
*
* It calls getMedianLineData internally with the raw scatterplot data and the computed by getters.getScatterPlotMainData.
* scatterPlotAddonQueryDays is necessary since we query the API with an additional day offset to compute the median.
*/
export const getScatterPlotMedianData = (state, getters) =>
getMedianLineData(
state.charts.scatterplot.data,
getters.getScatterPlotMainData,
scatterPlotAddonQueryDays,
);
export const getMetricDropdownLabel = state => chartKey => export const getMetricDropdownLabel = state => chartKey =>
metricTypes.find(m => m.key === state.charts[chartKey].params.metricType).label; metricTypes.find(m => m.key === state.charts[chartKey].params.metricType).label;
...@@ -58,7 +99,7 @@ export const getFilterParams = (state, getters, rootState, rootGetters) => chart ...@@ -58,7 +99,7 @@ export const getFilterParams = (state, getters, rootState, rootGetters) => chart
// common filter params // common filter params
const params = { const params = {
...rootGetters['filters/getCommonFilterParams'], ...rootGetters['filters/getCommonFilterParams'](chartKey),
chart_type: chartParams.chartType, chart_type: chartParams.chartType,
}; };
......
...@@ -38,6 +38,7 @@ export default () => ({ ...@@ -38,6 +38,7 @@ export default () => ({
selected: [], selected: [],
params: { params: {
chartType: chartTypes.scatterplot, chartType: chartTypes.scatterplot,
metricType: 'days_to_merge',
}, },
}, },
}, },
......
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import { chartKeys, scatterPlotAddonQueryDays } from '../../../constants';
/** /**
* Returns an object of common filter parameters based on the filter's state * Returns an object of common filter parameters based on the filter's state
...@@ -15,29 +16,25 @@ import { urlParamsToObject } from '~/lib/utils/common_utils'; ...@@ -15,29 +16,25 @@ import { urlParamsToObject } from '~/lib/utils/common_utils';
* } * }
* *
*/ */
export const getCommonFilterParams = (state, getters) => { export const getCommonFilterParams = state => chartKey => {
const { groupNamespace, projectPath, filters } = state; const { groupNamespace, projectPath, filters } = state;
const { author_username, milestone_title, label_name } = urlParamsToObject(filters); const { author_username, milestone_title, label_name } = urlParamsToObject(filters);
// for the scatterplot we need to add additional 30 days to the desired date in the past
const daysInPast =
chartKey && chartKey === chartKeys.scatterplot
? state.daysInPast + scatterPlotAddonQueryDays
: state.daysInPast;
return { return {
group_id: groupNamespace, group_id: groupNamespace,
project_id: projectPath, project_id: projectPath,
author_username, author_username,
milestone_title, milestone_title,
label_name, label_name,
merged_at_after: getters.mergedOnAfterDate, merged_at_after: `${daysInPast}days`,
}; };
}; };
/**
* Computes the "merged_at_after" date which will be used in the getCommonFilterParams getter.
* It subtracts the number of days (based on the state's daysInPast property) from today's date
* and returns the new date.
*/
export const mergedOnAfterDate = state => {
const d = new Date();
return new Date(d.setTime(d.getTime() - state.daysInPast * 24 * 60 * 60 * 1000)).toISOString();
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -9,7 +9,7 @@ export const fetchMergeRequests = ({ dispatch, state, rootState, rootGetters }) ...@@ -9,7 +9,7 @@ export const fetchMergeRequests = ({ dispatch, state, rootState, rootGetters })
const { sortField, sortOrder, pageInfo } = state; const { sortField, sortOrder, pageInfo } = state;
const params = { const params = {
...rootGetters['filters/getCommonFilterParams'], ...rootGetters['filters/getCommonFilterParams'](),
days_to_merge: rootState.charts.charts.main.selected, days_to_merge: rootState.charts.charts.main.selected,
sort: `${sortField}_${sortOrder}`, sort: `${sortField}_${sortOrder}`,
page: pageInfo ? pageInfo.page : null, page: pageInfo ? pageInfo.page : null,
......
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { median } from '~/lib/utils/number_utils';
/**
* Gets the labels endpoint for a given group or project
* @param {String} namespacePath - The group's namespace.
* @param {String} projectPathWithNamespace - The project's name including the group's namespace
* @returns {String} - The labels endpoint path
*/
export const getLabelsEndpoint = (namespacePath, projectPathWithNamespace) => { export const getLabelsEndpoint = (namespacePath, projectPathWithNamespace) => {
if (projectPathWithNamespace) { if (projectPathWithNamespace) {
return `/${projectPathWithNamespace}/-/labels`; return `/${projectPathWithNamespace}/-/labels`;
...@@ -6,6 +15,12 @@ export const getLabelsEndpoint = (namespacePath, projectPathWithNamespace) => { ...@@ -6,6 +15,12 @@ export const getLabelsEndpoint = (namespacePath, projectPathWithNamespace) => {
return `/groups/${namespacePath}/-/labels`; return `/groups/${namespacePath}/-/labels`;
}; };
/**
* Gets the milestones endpoint for a given group or project
* @param {String} namespacePath - The group's namespace.
* @param {String} projectPathWithNamespace - The project's name including the group's namespace
* @returns {String} - The milestones endpoint path
*/
export const getMilestonesEndpoint = (namespacePath, projectPathWithNamespace) => { export const getMilestonesEndpoint = (namespacePath, projectPathWithNamespace) => {
if (projectPathWithNamespace) { if (projectPathWithNamespace) {
return `/${projectPathWithNamespace}/-/milestones`; return `/${projectPathWithNamespace}/-/milestones`;
...@@ -13,3 +28,68 @@ export const getMilestonesEndpoint = (namespacePath, projectPathWithNamespace) = ...@@ -13,3 +28,68 @@ export const getMilestonesEndpoint = (namespacePath, projectPathWithNamespace) =
return `/groups/${namespacePath}/-/milestones`; return `/groups/${namespacePath}/-/milestones`;
}; };
/**
* Transforms a given data object into an array
* which will be used as series data for the scatterplot chart.
* It eliminates items which were merged before a "dateInPast" and sorts
* the result by date (ascending)
*
* Takes an object of the form
* {
* "1": { "metric": 138", merged_at": "2019-07-09T14:58:07.756Z" },
* "2": { "metric": 139, "merged_at": "2019-07-10T11:13:23.557Z" },
* "3": { "metric": 24, "merged_at": "2019-07-01T07:06:23.193Z" }
* }
*
* and creates the following two-dimensional array
* where the first value is the "merged_at" date and the second value is the metric:
*
* [
* ["2019-07-01T07:06:23.193Z", 24],
* ["2019-07-09T14:58:07.756Z", 138],
* ["2019-07-10T11:13:23.557Z", 139],
* ]
*
* @param {Object} data The raw data which will be transformed
* @param {String} dateInPast Date string in ISO format
* @returns {Array} The transformed data array sorted by date ascending
*/
export const getScatterPlotData = (data, dateInPast) =>
Object.keys(data)
.filter(key => new Date(data[key].merged_at) >= new Date(dateInPast))
.map(key => [data[key].merged_at, data[key].metric])
.sort((a, b) => new Date(a[0]) - new Date(b[0]));
/**
* Computes the moving median line data.
* It takes the raw data object (which contains historical data) and the scatterData (from getScatterPlotData)
* and computes the median for every date in scatterData.
* The median for a given date in scatterData (called item) is computed by taking all metrics of the raw data into account
* which are before (or eqaul to) the the item's merged_at date
* and after (or equal to) the item's merged_at date minus a given "daysOffset" (e.g., 30 days for "30 day rolling median")
*
* i.e., moving median for a given DAY is the median the range of values (DAY-30 ... DAY)
*
* @param {Object} data The raw data which will be used for computing the median
* @param {Array} scatterData The transformed data from getScatterPlotData
* @param {Number} daysOffset The number of days that is substracted from each date in scatterData (e.g. 30 days in the past)
* @returns {Array} An array with each item being another arry of two items (date, computed median)
*/
export const getMedianLineData = (data, scatterData, daysOffset) =>
scatterData.map(item => {
const [dateString] = item;
const values = Object.keys(data)
.filter(key => {
const mergedAtDate = new Date(data[key].merged_at);
const itemDate = new Date(dateString);
return (
mergedAtDate <= itemDate && mergedAtDate >= new Date(getDateInPast(itemDate, daysOffset))
);
})
.map(key => data[key].metric);
const computedMedian = values.length ? median(values) : 0;
return [dateString, computedMedian];
});
<script>
import dateFormat from 'dateformat';
import { GlDiscreteScatterChart } from '@gitlab/ui/dist/charts';
import { scatterChartLineProps, defaultDateFormat, defaultDateTimeFormat } from '../constants';
export default {
components: {
GlDiscreteScatterChart,
},
props: {
xAxisTitle: {
type: String,
required: true,
},
yAxisTitle: {
type: String,
required: true,
},
scatterData: {
type: Array,
required: true,
},
medianLineData: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
tooltipTitle: '',
tooltipContent: '',
chartOption: {
xAxis: {
axisLabel: {
formatter: date => dateFormat(date, defaultDateFormat),
},
},
dataZoom: [
{
type: 'slider',
bottom: 10,
start: 0,
},
],
},
};
},
computed: {
chartData() {
const result = [
{
type: 'scatter',
data: this.scatterData,
},
];
if (this.medianLineData.length) {
result.push({
data: this.medianLineData,
...scatterChartLineProps.default,
});
}
return result;
},
},
methods: {
renderTooltip({ data }) {
const [xValue, yValue] = data;
this.tooltipTitle = yValue;
this.tooltipContent = dateFormat(xValue, defaultDateTimeFormat);
},
},
};
</script>
<template>
<gl-discrete-scatter-chart
:data="chartData"
:option="chartOption"
:y-axis-title="yAxisTitle"
:x-axis-title="xAxisTitle"
:format-tooltip-text="renderTooltip"
>
<div slot="tooltipTitle">{{ tooltipTitle }}</div>
<div slot="tooltipContent">{{ tooltipContent }}</div>
</gl-discrete-scatter-chart>
</template>
export const defaultDateFormat = 'mmm d, yyyy';
export const defaultDateTimeFormat = 'mmm d, yyyy h:MMtt';
/**
* #1f78d1 --> $blue-500 (see variables.scss)
*/
export const scatterChartLineColor = '#1f78d1';
export const scatterChartLineProps = {
default: {
type: 'line',
showSymbol: false,
lineStyle: { color: scatterChartLineColor },
},
};
---
title: 'Productivity analytics: Add scatterplot'
merge_request: 15569
author:
type: other
...@@ -56,6 +56,7 @@ describe('ProductivityApp component', () => { ...@@ -56,6 +56,7 @@ describe('ProductivityApp component', () => {
const findSecondaryChartsSection = () => wrapper.find({ ref: 'secondaryCharts' }); const findSecondaryChartsSection = () => wrapper.find({ ref: 'secondaryCharts' });
const findTimeBasedMetricChart = () => wrapper.find({ ref: 'timeBasedChart' }); const findTimeBasedMetricChart = () => wrapper.find({ ref: 'timeBasedChart' });
const findCommitBasedMetricChart = () => wrapper.find({ ref: 'commitBasedChart' }); const findCommitBasedMetricChart = () => wrapper.find({ ref: 'commitBasedChart' });
const findScatterplotMetricChart = () => wrapper.find({ ref: 'scatterplot' });
const findMrTableSortSection = () => wrapper.find('.js-mr-table-sort'); const findMrTableSortSection = () => wrapper.find('.js-mr-table-sort');
const findSortFieldDropdown = () => findMrTableSortSection().find(GlDropdown); const findSortFieldDropdown = () => findMrTableSortSection().find(GlDropdown);
const findSortOrderToggle = () => findMrTableSortSection().find(GlButton); const findSortOrderToggle = () => findMrTableSortSection().find(GlButton);
...@@ -246,6 +247,47 @@ describe('ProductivityApp component', () => { ...@@ -246,6 +247,47 @@ describe('ProductivityApp component', () => {
}); });
}); });
describe('Scatterplot', () => {
it('renders a metric chart component', () => {
expect(findScatterplotMetricChart().exists()).toBe(true);
});
describe('when chart finished loading', () => {
describe('and the chart has data', () => {
beforeEach(() => {
wrapper.vm.$store.dispatch('charts/receiveChartDataSuccess', {
chartKey: chartKeys.scatterplot,
data: {
1: { metric: 2, merged_at: '2019-07-01T07:06:23.193Z' },
2: { metric: 3, merged_at: '2019-07-05T08:27:42.411Z' },
},
});
});
it('sets isLoading=false on the metric chart', () => {
expect(findScatterplotMetricChart().props('isLoading')).toBe(false);
});
it('passes non-empty chartData to the metric chart', () => {
expect(findScatterplotMetricChart().props('chartData')).not.toEqual([]);
});
describe('when the user changes the metric', () => {
beforeEach(() => {
findScatterplotMetricChart().vm.$emit('metricTypeChange', 'loc_per_commit');
});
it('should call setMetricType when `metricTypeChange` is emitted on the metric chart', () => {
expect(actionSpies.setMetricType).toHaveBeenCalledWith({
metricType: 'loc_per_commit',
chartKey: chartKeys.scatterplot,
});
});
});
});
});
});
describe('MR table', () => { describe('MR table', () => {
describe('when table is loading', () => { describe('when table is loading', () => {
beforeEach(() => { beforeEach(() => {
......
...@@ -75,3 +75,86 @@ export const mockHistogramData = { ...@@ -75,3 +75,86 @@ export const mockHistogramData = {
'40': 40, '40': 40,
'41': 41, '41': 41,
}; };
export const mockScatterplotData = {
'1': {
metric: 139,
merged_at: '2019-08-18T22:00:00.000Z',
},
'2': {
metric: 138,
merged_at: '2019-08-17T22:00:00.000Z',
},
'3': {
metric: 24,
merged_at: '2019-08-16T22:00:00.000Z',
},
'4': {
metric: 56,
merged_at: '2019-08-15T22:00:00.000Z',
},
'5': {
metric: 46,
merged_at: '2019-08-14T22:00:00.000Z',
},
'6': {
metric: 43,
merged_at: '2019-08-13T22:00:00.000Z',
},
'7': {
metric: 60,
merged_at: '2019-08-12T22:00:00.000Z',
},
'8': {
metric: 62,
merged_at: '2019-08-11T22:00:00.000Z',
},
'9': {
metric: 46,
merged_at: '2019-08-10T22:00:00.000Z',
},
'10': {
metric: 44,
merged_at: '2019-08-09T22:00:00.000Z',
},
'11': {
metric: 57,
merged_at: '2019-08-08T22:00:00.000Z',
},
'12': {
metric: 51,
merged_at: '2019-08-07T22:00:00.000Z',
},
'13': {
metric: 54,
merged_at: '2019-08-06T22:00:00.000Z',
},
'14': {
metric: 64,
merged_at: '2019-08-05T22:00:00.000Z',
},
'15': {
metric: 52,
merged_at: '2019-08-04T22:00:00.000Z',
},
'16': {
metric: 56,
merged_at: '2019-08-03T22:00:00.000Z',
},
'17': {
metric: 47,
merged_at: '2019-08-02T22:00:00.000Z',
},
'18': {
metric: 49,
merged_at: '2019-08-01T22:00:00.000Z',
},
'19': {
metric: 46,
merged_at: '2019-07-31T22:00:00.000Z',
},
'20': {
metric: 57,
merged_at: '2019-07-30T22:00:00.000Z',
},
};
...@@ -200,11 +200,7 @@ describe('Productivity analytics chart actions', () => { ...@@ -200,11 +200,7 @@ describe('Productivity analytics chart actions', () => {
{ chartKey, item }, { chartKey, item },
mockedContext.state, mockedContext.state,
[{ type: types.UPDATE_SELECTED_CHART_ITEMS, payload: { chartKey, item } }], [{ type: types.UPDATE_SELECTED_CHART_ITEMS, payload: { chartKey, item } }],
[ [{ type: 'fetchSecondaryChartData' }, { type: 'table/fetchMergeRequests', payload: null }],
{ type: 'fetchChartData', payload: chartKeys.timeBasedHistogram },
{ type: 'fetchChartData', payload: chartKeys.commitBasedHistogram },
{ type: 'table/fetchMergeRequests', payload: null },
],
done, done,
); );
}); });
......
...@@ -4,8 +4,15 @@ import { ...@@ -4,8 +4,15 @@ import {
chartKeys, chartKeys,
columnHighlightStyle, columnHighlightStyle,
maxColumnChartItemsPerPage, maxColumnChartItemsPerPage,
scatterPlotAddonQueryDays,
} from 'ee/analytics/productivity_analytics/constants'; } from 'ee/analytics/productivity_analytics/constants';
import { mockHistogramData } from '../../../mock_data'; import { getScatterPlotData, getMedianLineData } from 'ee/analytics/productivity_analytics/utils';
import { mockHistogramData, mockScatterplotData } from '../../../mock_data';
jest.mock('ee/analytics/productivity_analytics/utils');
jest.mock('~/lib/utils/datetime_utility', () => ({
getDateInPast: jest.fn().mockReturnValue('2019-07-16T00:00:00.00Z'),
}));
describe('Productivity analytics chart getters', () => { describe('Productivity analytics chart getters', () => {
let state; let state;
...@@ -27,8 +34,8 @@ describe('Productivity analytics chart getters', () => { ...@@ -27,8 +34,8 @@ describe('Productivity analytics chart getters', () => {
}); });
}); });
describe('getChartData', () => { describe('getColumnChartData', () => {
it("parses the chart's data and adds a color property to selected items", () => { it("parses the column chart's data and adds a color property to selected items", () => {
const chartKey = chartKeys.main; const chartKey = chartKeys.main;
state.charts[chartKey] = { state.charts[chartKey] = {
data: { data: {
...@@ -43,7 +50,44 @@ describe('Productivity analytics chart getters', () => { ...@@ -43,7 +50,44 @@ describe('Productivity analytics chart getters', () => {
{ value: ['5', 17], itemStyle: columnHighlightStyle }, { value: ['5', 17], itemStyle: columnHighlightStyle },
]; ];
expect(getters.getChartData(state)(chartKey)).toEqual(chartData); expect(getters.getColumnChartData(state)(chartKey)).toEqual(chartData);
});
});
describe('getScatterPlotMainData', () => {
it('calls getScatterPlotData with the raw scatterplot data and the date in past', () => {
state.charts.scatterplot.data = mockScatterplotData;
const rootState = {
filters: {
daysInPast: 30,
},
};
getters.getScatterPlotMainData(state, null, rootState);
expect(getScatterPlotData).toHaveBeenCalledWith(
mockScatterplotData,
'2019-07-16T00:00:00.00Z',
);
});
});
describe('getScatterPlotMedianData', () => {
it('calls getMedianLineData with the raw scatterplot data, the getScatterPlotMainData getter and the an additional days offset', () => {
state.charts.scatterplot.data = mockScatterplotData;
const mockGetters = {
getScatterPlotMainData: jest.fn(),
};
getters.getScatterPlotMedianData(state, mockGetters);
expect(getMedianLineData).toHaveBeenCalledWith(
mockScatterplotData,
mockGetters.getScatterPlotMainData,
scatterPlotAddonQueryDays,
);
}); });
}); });
...@@ -62,9 +106,12 @@ describe('Productivity analytics chart getters', () => { ...@@ -62,9 +106,12 @@ describe('Productivity analytics chart getters', () => {
describe('getFilterParams', () => { describe('getFilterParams', () => {
const rootGetters = {}; const rootGetters = {};
rootGetters['filters/getCommonFilterParams'] = { rootGetters['filters/getCommonFilterParams'] = () => {
group_id: groupNamespace, const params = {
project_id: projectPath, group_id: groupNamespace,
project_id: projectPath,
};
return params;
}; };
describe('main chart', () => { describe('main chart', () => {
...@@ -157,11 +204,6 @@ describe('Productivity analytics chart getters', () => { ...@@ -157,11 +204,6 @@ describe('Productivity analytics chart getters', () => {
start: 0, start: 0,
end: intervalEnd, end: intervalEnd,
}, },
{
type: 'inside',
start: 0,
end: intervalEnd,
},
], ],
}; };
......
import createState from 'ee/analytics/productivity_analytics/store/modules/filters/state'; import createState from 'ee/analytics/productivity_analytics/store/modules/filters/state';
import * as getters from 'ee/analytics/productivity_analytics/store/modules/filters/getters'; import * as getters from 'ee/analytics/productivity_analytics/store/modules/filters/getters';
import { chartKeys } from 'ee/analytics/productivity_analytics/constants';
describe('Productivity analytics filter getters', () => { describe('Productivity analytics filter getters', () => {
let state; let state;
...@@ -9,42 +10,47 @@ describe('Productivity analytics filter getters', () => { ...@@ -9,42 +10,47 @@ describe('Productivity analytics filter getters', () => {
}); });
describe('getCommonFilterParams', () => { describe('getCommonFilterParams', () => {
it('returns an object with group_id, project_id and all relevant params from the filters string', () => { beforeEach(() => {
state = { state = {
groupNamespace: 'gitlab-org', groupNamespace: 'gitlab-org',
projectPath: 'gitlab-org/gitlab-test', projectPath: 'gitlab-org/gitlab-test',
filters: '?author_username=root&milestone_title=foo&label_name[]=labelxyz', filters: '?author_username=root&milestone_title=foo&label_name[]=labelxyz',
daysInPast: 30,
}; };
const mockGetters = { mergedOnAfterDate: '2019-07-16T00:00:00.00Z' };
const expected = {
author_username: 'root',
group_id: 'gitlab-org',
label_name: ['labelxyz'],
merged_at_after: '2019-07-16T00:00:00.00Z',
milestone_title: 'foo',
project_id: 'gitlab-org/gitlab-test',
};
const result = getters.getCommonFilterParams(state, mockGetters);
expect(result).toEqual(expected);
}); });
});
describe('mergedOnAfterDate', () => { describe('when chart is not scatterplot', () => {
beforeEach(() => { it('returns an object with common filter params', () => {
const mockedTimestamp = 1563235200000; // 2019-07-16T00:00:00.00Z const expected = {
jest.spyOn(Date.prototype, 'getTime').mockReturnValue(mockedTimestamp); author_username: 'root',
group_id: 'gitlab-org',
label_name: ['labelxyz'],
merged_at_after: '30days',
milestone_title: 'foo',
project_id: 'gitlab-org/gitlab-test',
};
const result = getters.getCommonFilterParams(state)(chartKeys.main);
expect(result).toEqual(expected);
});
}); });
it('returns the correct date in the past', () => {
state = {
daysInPast: 90,
};
const mergedOnAfterDate = getters.mergedOnAfterDate(state);
expect(mergedOnAfterDate).toBe('2019-04-17T00:00:00.000Z'); describe('when chart is scatterplot', () => {
it('returns an object with common filter params and adds additional days to the merged_at_after property', () => {
const expected = {
author_username: 'root',
group_id: 'gitlab-org',
label_name: ['labelxyz'],
merged_at_after: '60days',
milestone_title: 'foo',
project_id: 'gitlab-org/gitlab-test',
};
const result = getters.getCommonFilterParams(state)(chartKeys.scatterplot);
expect(result).toEqual(expected);
});
}); });
}); });
}); });
...@@ -56,9 +56,12 @@ describe('Productivity analytics table actions', () => { ...@@ -56,9 +56,12 @@ describe('Productivity analytics table actions', () => {
}, },
rootGetters: { rootGetters: {
// eslint-disable-next-line no-useless-computed-key // eslint-disable-next-line no-useless-computed-key
['filters/getCommonFilterParams']: { ['filters/getCommonFilterParams']: () => {
group_id: groupNamespace, const params = {
project_id: projectPath, group_id: groupNamespace,
project_id: projectPath,
};
return params;
}, },
}, },
state: getInitialState(), state: getInitialState(),
......
import { import {
getLabelsEndpoint, getLabelsEndpoint,
getMilestonesEndpoint, getMilestonesEndpoint,
getScatterPlotData,
getMedianLineData,
} from 'ee/analytics/productivity_analytics/utils'; } from 'ee/analytics/productivity_analytics/utils';
import { mockScatterplotData } from './mock_data';
describe('Productivity Analytics utils', () => { describe('Productivity Analytics utils', () => {
const namespacePath = 'gitlab-org'; const namespacePath = 'gitlab-org';
const projectWithNamespace = 'gitlab-org/gitlab-test'; const projectWithNamespace = 'gitlab-org/gitlab-test';
...@@ -30,4 +34,43 @@ describe('Productivity Analytics utils', () => { ...@@ -30,4 +34,43 @@ describe('Productivity Analytics utils', () => {
); );
}); });
}); });
describe('getScatterPlotData', () => {
it('filters out data before given "dateInPast", transforms the data and sorts by date ascending', () => {
const dateInPast = '2019-08-09T22:00:00.000Z';
const result = getScatterPlotData(mockScatterplotData, dateInPast);
const expected = [
['2019-08-09T22:00:00.000Z', 44],
['2019-08-10T22:00:00.000Z', 46],
['2019-08-11T22:00:00.000Z', 62],
['2019-08-12T22:00:00.000Z', 60],
['2019-08-13T22:00:00.000Z', 43],
['2019-08-14T22:00:00.000Z', 46],
['2019-08-15T22:00:00.000Z', 56],
['2019-08-16T22:00:00.000Z', 24],
['2019-08-17T22:00:00.000Z', 138],
['2019-08-18T22:00:00.000Z', 139],
];
expect(result).toEqual(expected);
});
});
describe('getMedianLineData', () => {
const daysOffset = 10;
it(`computes the median for every item in the scatterData array for the past ${daysOffset} days`, () => {
const scatterData = [
['2019-08-16T22:00:00.000Z', 24],
['2019-08-17T22:00:00.000Z', 138],
['2019-08-18T22:00:00.000Z', 139],
];
const result = getMedianLineData(mockScatterplotData, scatterData, daysOffset);
const expected = [
['2019-08-16T22:00:00.000Z', 51],
['2019-08-17T22:00:00.000Z', 51],
['2019-08-18T22:00:00.000Z', 56],
];
expect(result).toEqual(expected);
});
});
}); });
...@@ -4752,6 +4752,9 @@ msgstr "" ...@@ -4752,6 +4752,9 @@ msgstr ""
msgid "Days" msgid "Days"
msgstr "" msgstr ""
msgid "Days to merge"
msgstr ""
msgid "Debug" msgid "Debug"
msgstr "" msgstr ""
...@@ -8096,9 +8099,6 @@ msgstr "" ...@@ -8096,9 +8099,6 @@ msgstr ""
msgid "Hook was successfully updated." msgid "Hook was successfully updated."
msgstr "" msgstr ""
msgid "Hours"
msgstr ""
msgid "Housekeeping" msgid "Housekeeping"
msgstr "" msgstr ""
...@@ -11599,15 +11599,42 @@ msgstr "" ...@@ -11599,15 +11599,42 @@ msgstr ""
msgid "Productivity analytics can help identify the problems that are delaying your team" msgid "Productivity analytics can help identify the problems that are delaying your team"
msgstr "" msgstr ""
msgid "ProductivityAanalytics|Merge requests"
msgstr ""
msgid "ProductivityAnalytics|Ascending" msgid "ProductivityAnalytics|Ascending"
msgstr "" msgstr ""
msgid "ProductivityAnalytics|Days"
msgstr ""
msgid "ProductivityAnalytics|Days to merge" msgid "ProductivityAnalytics|Days to merge"
msgstr "" msgstr ""
msgid "ProductivityAnalytics|Descending" msgid "ProductivityAnalytics|Descending"
msgstr "" msgstr ""
msgid "ProductivityAnalytics|Hours"
msgstr ""
msgid "ProductivityAnalytics|List"
msgstr ""
msgid "ProductivityAnalytics|Merge Requests"
msgstr ""
msgid "ProductivityAnalytics|Merge date"
msgstr ""
msgid "ProductivityAnalytics|Merge requests"
msgstr ""
msgid "ProductivityAnalytics|Time to merge"
msgstr ""
msgid "ProductivityAnalytics|Trendline"
msgstr ""
msgid "Profile" msgid "Profile"
msgstr "" msgstr ""
......
...@@ -426,3 +426,13 @@ describe('newDate', () => { ...@@ -426,3 +426,13 @@ describe('newDate', () => {
expect(initialDate instanceof Date).toBe(true); expect(initialDate instanceof Date).toBe(true);
}); });
}); });
describe('getDateInPast', () => {
it('returns the correct date in the past', () => {
const date = new Date(1563235200000); // 2019-07-16T00:00:00.00Z
const daysInPast = 90;
const dateInPast = datetimeUtility.getDateInPast(date, daysInPast);
expect(dateInPast).toBe('2019-04-17T00:00:00.000Z');
});
});
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
numberToHumanSize, numberToHumanSize,
sum, sum,
isOdd, isOdd,
median,
} from '~/lib/utils/number_utils'; } from '~/lib/utils/number_utils';
describe('Number Utils', () => { describe('Number Utils', () => {
...@@ -109,4 +110,16 @@ describe('Number Utils', () => { ...@@ -109,4 +110,16 @@ describe('Number Utils', () => {
expect(isOdd(1)).toEqual(1); expect(isOdd(1)).toEqual(1);
}); });
}); });
describe('median', () => {
it('computes the median for a given array with odd length', () => {
const items = [10, 27, 20, 5, 19];
expect(median(items)).toBe(19);
});
it('computes the median for a given array with even length', () => {
const items = [10, 27, 20, 5, 19, 4];
expect(median(items)).toBe(14.5);
});
});
}); });
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