Commit 61616728 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'psi-burnup' into 'master'

Add burnup chart FE

See merge request gitlab-org/gitlab!29652
parents 54f0fd8c b281d49c
import dateFormat from 'dateformat';
export default class BurndownChartData {
constructor(burndownEvents, startDate, dueDate) {
export default class BurnChartData {
constructor(events, startDate, dueDate) {
this.dateFormatMask = 'yyyy-mm-dd';
this.startDate = startDate;
this.dueDate = dueDate;
this.burndownEvents = this.processRawEvents(burndownEvents);
// determine when to stop burndown chart
const today = dateFormat(Date.now(), this.dateFormatMask);
......@@ -15,11 +14,63 @@ export default class BurndownChartData {
// and dateFormat() both convert the date at midnight UTC to the browser's
// timezone, leading to incorrect chart start and end points. Using
// new Date('YYYY-MM-DDTHH:MM:SS') gets the user's local date at midnight.
this.localStartDate = new Date(`${this.startDate}T00:00:00`);
this.localEndDate = new Date(`${this.endDate}T00:00:00`);
this.events = this.processRawEvents(events);
}
generateBurnupTimeseries({ milestoneId } = {}) {
const chartData = [];
let openIssuesCount = 0;
let carriedIssuesCount = 0;
for (
let date = this.localStartDate;
date <= this.localEndDate;
date.setDate(date.getDate() + 1)
) {
const dateString = dateFormat(date, this.dateFormatMask);
const openedIssuesToday = this.filterAndSummarizeBurndownEvents(
event =>
event.created_at === dateString &&
event.event_type === 'milestone' &&
event.milestone_id === milestoneId &&
event.action === 'add',
);
const closedIssuesToday = this.filterAndSummarizeBurndownEvents(
event =>
event.created_at === dateString &&
event.event_type === 'milestone' &&
((event.action === 'remove' && event.milestone_id === milestoneId) ||
(event.action === 'add' && event.milestone_id !== milestoneId)),
);
openIssuesCount += openedIssuesToday.count - closedIssuesToday.count;
if (openIssuesCount + carriedIssuesCount < 0) {
carriedIssuesCount += openIssuesCount;
openIssuesCount = 0;
} else {
openIssuesCount += carriedIssuesCount;
carriedIssuesCount = 0;
}
chartData.push([dateString, openIssuesCount]);
}
return {
burnupScope: chartData,
};
}
generate() {
generateBurndownTimeseries() {
let openIssuesCount = 0;
let openIssuesWeight = 0;
......@@ -87,7 +138,7 @@ export default class BurndownChartData {
}
filterAndSummarizeBurndownEvents(filter) {
const issues = this.burndownEvents.filter(filter);
const issues = this.events.filter(filter);
return {
count: issues.length,
......
......@@ -3,12 +3,14 @@ import { GlButton, GlButtonGroup } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
import BurndownChart from './burndown_chart.vue';
import BurnupChart from './burnup_chart.vue';
export default {
components: {
GlButton,
GlButtonGroup,
BurndownChart,
BurnupChart,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -30,6 +32,11 @@ export default {
required: false,
default: () => [],
},
burnupScope: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
......@@ -90,6 +97,12 @@ export default {
:issues-selected="issuesSelected"
class="col-md-6"
/>
<burnup-chart
:start-date="startDate"
:due-date="dueDate"
:scope="burnupScope"
class="col-md-6"
/>
</div>
<burndown-chart
v-else
......
......@@ -63,13 +63,13 @@ export default {
const series = [
{
name,
data: data.map(d => [new Date(d[0]), d[1]]),
data,
},
];
if (series[0] && series[0].data.length >= 2) {
const idealStart = [new Date(this.startDate), data[0][1]];
const idealEnd = [new Date(this.dueDate), 0];
const idealStart = [this.startDate, data[0][1]];
const idealEnd = [this.dueDate, 0];
const idealData = [idealStart, idealEnd];
series.push({
......@@ -91,6 +91,8 @@ export default {
xAxis: {
name: '',
type: 'time',
min: this.startDate,
max: this.dueDate,
axisLine: {
show: true,
},
......@@ -141,7 +143,7 @@ export default {
<div v-if="showTitle" class="burndown-header d-flex align-items-center">
<h3>{{ __('Burndown chart') }}</h3>
</div>
<resizable-chart-container class="burndown-chart">
<resizable-chart-container class="burndown-chart js-burndown-chart">
<gl-line-chart
slot-scope="{ width }"
:width="width"
......
<script>
import { GlLineChart } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { __, sprintf } from '~/locale';
export default {
components: {
GlLineChart,
ResizableChartContainer,
},
props: {
startDate: {
type: String,
required: true,
},
dueDate: {
type: String,
required: true,
},
scope: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
tooltip: {
title: '',
content: '',
},
};
},
computed: {
dataSeries() {
const series = [
{
name: __('Total'),
data: this.scope,
},
];
return series;
},
options() {
return {
xAxis: {
name: '',
type: 'time',
min: this.startDate,
max: this.dueDate,
axisLine: {
show: true,
},
},
yAxis: {
name: __('Total issues'),
axisLine: {
show: true,
},
splitLine: {
show: false,
},
},
tooltip: {
trigger: 'item',
formatter: () => '',
},
};
},
},
methods: {
formatTooltipText(params) {
const [seriesData] = params.seriesData;
this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy');
const text = __('%{total} open issues');
this.tooltip.content = sprintf(text, {
total: seriesData.value[1],
});
},
},
};
</script>
<template>
<div data-qa-selector="burnup_chart">
<div class="burndown-header d-flex align-items-center">
<h3>{{ __('Burnup chart') }}</h3>
</div>
<resizable-chart-container class="js-burnup-chart">
<gl-line-chart :data="dataSeries" :option="options" :format-tooltip-text="formatTooltipText">
<template slot="tooltipTitle">{{ tooltip.title }}</template>
<template slot="tooltipContent">{{ tooltip.content }}</template>
</gl-line-chart>
</resizable-chart-container>
</div>
</template>
......@@ -2,8 +2,8 @@ import Vue from 'vue';
import $ from 'jquery';
import Cookies from 'js-cookie';
import BurnCharts from './components/burn_charts.vue';
import BurndownChartData from './burndown_chart_data';
import Flash from '~/flash';
import BurndownChartData from './burn_chart_data';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
......@@ -22,16 +22,34 @@ export default () => {
if ($chartEl.length) {
const startDate = $chartEl.data('startDate');
const dueDate = $chartEl.data('dueDate');
const milestoneId = $chartEl.data('milestoneId');
const burndownEventsPath = $chartEl.data('burndownEventsPath');
const burnupEventsPath = $chartEl.data('burnupEventsPath');
axios
.get(burndownEventsPath)
.then(response => {
const burndownEvents = response.data;
const chartData = new BurndownChartData(burndownEvents, startDate, dueDate).generate();
const fetchData = [axios.get(burndownEventsPath)];
const openIssuesCount = chartData.map(d => [d[0], d[1]]);
const openIssuesWeight = chartData.map(d => [d[0], d[2]]);
if (gon.features.burnupCharts) {
fetchData.push(axios.get(burnupEventsPath));
}
Promise.all(fetchData)
.then(([burndownResponse, burnupResponse]) => {
const burndownEvents = burndownResponse.data;
const burndownChartData = new BurndownChartData(
burndownEvents,
startDate,
dueDate,
).generateBurndownTimeseries();
const burnupEvents = burnupResponse?.data || [];
const { burnupScope } =
new BurndownChartData(burnupEvents, startDate, dueDate).generateBurnupTimeseries({
milestoneId,
}) || {};
const openIssuesCount = burndownChartData.map(d => [d[0], d[1]]);
const openIssuesWeight = burndownChartData.map(d => [d[0], d[2]]);
return new Vue({
el: container,
......@@ -45,11 +63,14 @@ export default () => {
dueDate,
openIssuesCount,
openIssuesWeight,
burnupScope,
},
});
},
});
})
.catch(() => new Flash(__('Error loading burndown chart data')));
.catch(() => {
createFlash(__('Error loading burndown chart data'));
});
}
};
......@@ -2,13 +2,15 @@
- burndown = burndown_chart(milestone)
- warning = data_warning_for(burndown)
- burndown_endpoint = milestone.group_milestone? ? api_v4_groups_milestones_burndown_events_path(id: milestone.group.id, milestone_id: milestone.id) : api_v4_projects_milestones_burndown_events_path(id: milestone.project.id, milestone_id: milestone.timebox_id)
- burnup_endpoint = milestone.group_milestone? ? api_v4_groups_milestones_burnup_events_path(id: milestone.group.id, milestone_id: milestone.id) : api_v4_projects_milestones_burnup_events_path(id: milestone.project.id, milestone_id: milestone.timebox_id)
= warning
- if can_generate_chart?(milestone, burndown)
.burndown-chart.mb-2{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"),
due_date: burndown.due_date.strftime("%Y-%m-%d"),
burndown_events_path: expose_url(burndown_endpoint), qa_selector: 'burndown_chart' } }
milestone_id: milestone.id,
burndown_events_path: expose_url(burndown_endpoint), burnup_events_path: expose_url(burnup_endpoint), qa_selector: 'burndown_chart' } }
- elsif show_burndown_placeholder?(milestone, warning)
.burndown-hint.content-block.container-fluid
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Burnup charts', :js do
let_it_be(:burnup_chart_selector) { '.js-burnup-chart' }
let_it_be(:burndown_chart_selector) { '.js-burndown-chart' }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:milestone) { create(:milestone, :with_dates, group: group, title: 'January Milestone', description: 'Cut scope from milestone', start_date: '2020-01-30', due_date: '2020-02-10') }
let_it_be(:other_milestone) { create(:milestone, :with_dates, group: group, title: 'February Milestone', description: 'burnup sample', start_date: '2020-02-11', due_date: '2020-02-28') }
let_it_be(:issue_1) { create(:issue, created_at: '2020-01-30', project: project, milestone: milestone, weight: 2) }
let_it_be(:issue_2) { create(:issue, created_at: '2020-01-30', project: project, milestone: milestone, weight: 3) }
let_it_be(:issue_3) { create(:issue, created_at: '2020-01-30', project: project, milestone: milestone, weight: 2) }
let_it_be(:event1) { create(:resource_milestone_event, issue: issue_1, milestone: milestone, action: 'add', created_at: '2020-01-30') }
let_it_be(:event2) { create(:resource_milestone_event, issue: issue_2, milestone: milestone, action: 'add', created_at: '2020-01-30') }
let_it_be(:event3) { create(:resource_milestone_event, issue: issue_3, milestone: milestone, action: 'add', created_at: '2020-01-30') }
let_it_be(:event4) { create(:resource_milestone_event, issue: issue_2, milestone: milestone, action: 'remove', created_at: '2020-02-06') }
let_it_be(:event5) { create(:resource_milestone_event, issue: issue_3, milestone: other_milestone, action: 'add', created_at: '2020-02-06') }
before do
group.add_developer(user)
sign_in(user)
end
describe 'licensed feature available' do
before do
stub_licensed_features(group_burndown_charts: true)
end
it 'shows burnup chart, with a point per day' do
visit group_milestone_path(milestone.group, milestone)
expect(burnup_chart_points.count).to be(12)
end
end
describe 'licensed feature not available' do
before do
stub_licensed_features(group_burndown_charts: false)
end
it 'does not show burnup chart' do
visit group_milestone_path(milestone.group, milestone)
expect(page).not_to have_selector(burnup_chart_selector)
end
end
describe 'feature flag disabled' do
before do
stub_licensed_features(group_burndown_charts: true)
stub_feature_flags(burnup_charts: false)
end
it 'only shows burndown chart' do
visit group_milestone_path(milestone.group, milestone)
expect(page).to have_selector(burndown_chart_selector)
expect(page).not_to have_selector(burnup_chart_selector)
end
end
def burnup_chart_points
fill_color = "#5772ff"
burnup_chart.all("path[fill='#{fill_color}']", count: 12)
end
def burnup_chart
page.find(burnup_chart_selector)
end
end
import dateFormat from 'dateformat';
import timezoneMock from 'timezone-mock';
import BurndownChartData from 'ee/burndown_chart/burndown_chart_data';
import BurndownChartData from 'ee/burndown_chart/burn_chart_data';
describe('BurndownChartData', () => {
const startDate = '2017-03-01';
const dueDate = '2017-03-03';
const milestoneEvents = [
const issueStateEvents = [
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' },
......@@ -16,15 +16,15 @@ describe('BurndownChartData', () => {
{ created_at: '2017-03-03T00:00:00.000Z', weight: 2, action: 'reopened' },
];
let burndownChartData;
describe('generateBurndownTimeseries', () => {
let burndownChartData;
beforeEach(() => {
burndownChartData = new BurndownChartData(milestoneEvents, startDate, dueDate);
});
beforeEach(() => {
burndownChartData = new BurndownChartData(issueStateEvents, startDate, dueDate);
});
describe('generate', () => {
it('generates an array of arrays with date, issue count and weight', () => {
expect(burndownChartData.generate()).toEqual([
expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 2, 4],
['2017-03-02', 1, 2],
['2017-03-03', 3, 6],
......@@ -41,7 +41,7 @@ describe('BurndownChartData', () => {
});
it('has the right start and end dates', () => {
expect(burndownChartData.generate()).toEqual([
expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 1, 2],
['2017-03-02', 3, 6],
['2017-03-03', 3, 6],
......@@ -51,7 +51,7 @@ describe('BurndownChartData', () => {
describe('when issues are created before start date', () => {
beforeAll(() => {
milestoneEvents.push({
issueStateEvents.push({
created_at: '2017-02-28T00:00:00.000Z',
weight: 2,
action: 'created',
......@@ -59,7 +59,7 @@ describe('BurndownChartData', () => {
});
it('generates an array of arrays with date, issue count and weight', () => {
expect(burndownChartData.generate()).toEqual([
expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 3, 6],
['2017-03-02', 2, 4],
['2017-03-03', 4, 8],
......@@ -80,7 +80,7 @@ describe('BurndownChartData', () => {
});
it('counts until today if milestone due date > date today', () => {
const chartData = burndownChartData.generate();
const chartData = burndownChartData.generateBurndownTimeseries();
expect(dateFormat(Date.now(), 'yyyy-mm-dd')).toEqual('2017-03-02');
expect(chartData[chartData.length - 1][0]).toEqual('2017-03-02');
......@@ -90,8 +90,8 @@ describe('BurndownChartData', () => {
describe('when days in milestone have negative counts', () => {
describe('and the first two days have a negative count', () => {
beforeAll(() => {
milestoneEvents.length = 0;
milestoneEvents.push(
issueStateEvents.length = 0;
issueStateEvents.push(
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' },
......@@ -101,7 +101,7 @@ describe('BurndownChartData', () => {
});
it('generates an array where the first two days counts are zero', () => {
expect(burndownChartData.generate()).toEqual([
expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 0, 0],
['2017-03-02', 0, 0],
['2017-03-03', 1, 2],
......@@ -115,8 +115,8 @@ describe('BurndownChartData', () => {
// potential edge case.
beforeAll(() => {
milestoneEvents.length = 0;
milestoneEvents.push(
issueStateEvents.length = 0;
issueStateEvents.push(
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' },
......@@ -126,7 +126,7 @@ describe('BurndownChartData', () => {
});
it('generates an array where the middle day count is zero', () => {
expect(burndownChartData.generate()).toEqual([
expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 1, 2],
['2017-03-02', 0, 0],
['2017-03-03', 1, 2],
......@@ -140,8 +140,8 @@ describe('BurndownChartData', () => {
// potential edge case.
beforeAll(() => {
milestoneEvents.length = 0;
milestoneEvents.push(
issueStateEvents.length = 0;
issueStateEvents.push(
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' },
......@@ -151,7 +151,7 @@ describe('BurndownChartData', () => {
});
it('generates an array where all counts are zero', () => {
expect(burndownChartData.generate()).toEqual([
expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 0, 0],
['2017-03-02', 0, 0],
['2017-03-03', 0, 0],
......@@ -160,4 +160,192 @@ describe('BurndownChartData', () => {
});
});
});
describe('generateBurnupTimeseries', () => {
const milestoneId = 400;
const milestoneEvents = [
// day 1: add two issues to the milestone
{
action: 'add',
created_at: '2017-03-01T00:00:00.000Z',
event_type: 'milestone',
issue_id: 1,
milestone_id: milestoneId,
weight: null,
},
{
action: 'add',
created_at: '2017-03-01T00:00:00.000Z',
event_type: 'milestone',
issue_id: 2,
milestone_id: milestoneId,
weight: null,
},
// day 2: remove both issues we added yesterday, add a different issue
{
action: 'remove',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 2,
milestone_id: milestoneId,
weight: null,
},
{
action: 'add',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 3,
milestone_id: milestoneId,
weight: null,
},
{
action: 'remove',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 1,
milestone_id: milestoneId,
weight: null,
},
// day 3: remove yesterday's issue, also remove an issue that didn't have an `add` event
{
action: 'remove',
created_at: '2017-03-03T00:00:00.000Z',
event_type: 'milestone',
issue_id: 2,
milestone_id: milestoneId,
weight: null,
},
{
action: 'remove',
created_at: '2017-03-03T00:00:00.000Z',
event_type: 'milestone',
issue_id: 4,
milestone_id: milestoneId,
weight: null,
},
];
const burndownChartData = (events = milestoneEvents) => {
return new BurndownChartData(events, startDate, dueDate);
};
it('generates an array of arrays with date and issue count', () => {
const { burnupScope } = burndownChartData().generateBurnupTimeseries({ milestoneId });
expect(burnupScope).toEqual([['2017-03-01', 2], ['2017-03-02', 1], ['2017-03-03', 0]]);
});
it('starts from 0', () => {
const { burnupScope } = burndownChartData([]).generateBurnupTimeseries({
milestoneId,
});
expect(burnupScope[0]).toEqual(['2017-03-01', 0], ['2017-03-01', 0], ['2017-03-01', 0]);
});
it('does not go below zero with extra remove events', () => {
const { burnupScope } = burndownChartData([
{
action: 'remove',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 2,
milestone_id: milestoneId,
weight: null,
},
{
action: 'remove',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 1,
milestone_id: milestoneId,
weight: null,
},
]).generateBurnupTimeseries({
milestoneId,
});
expect(burnupScope).toEqual([['2017-03-01', 0], ['2017-03-02', 0], ['2017-03-03', 0]]);
});
it('ignores removed from other milestones', () => {
const differentMilestoneId = 600;
const events = [
{
created_at: '2017-03-01T00:00:00.000Z',
action: 'add',
event_type: 'milestone',
milestone_id: milestoneId,
issue_id: 1,
},
{
created_at: '2017-03-01T00:00:00.000Z',
action: 'remove',
event_type: 'milestone',
milestone_id: differentMilestoneId,
issue_id: 1,
},
];
const { burnupScope } = burndownChartData(events).generateBurnupTimeseries({ milestoneId });
expect(burnupScope).toEqual([['2017-03-01', 1], ['2017-03-02', 1], ['2017-03-03', 1]]);
});
it('only adds milestone event_type', () => {
const events = [
{
created_at: '2017-03-01T00:00:00.000Z',
action: 'add',
event_type: 'weight',
milestone_id: milestoneId,
issue_id: 1,
weight: 2,
},
{
created_at: '2017-03-02T00:00:00.000Z',
action: 'add',
event_type: 'milestone',
milestone_id: milestoneId,
issue_id: 1,
weight: null,
},
];
const { burnupScope } = burndownChartData(events).generateBurnupTimeseries({ milestoneId });
expect(burnupScope).toEqual([['2017-03-01', 0], ['2017-03-02', 1], ['2017-03-03', 1]]);
});
it('only removes milestone event_type', () => {
const events = [
{
created_at: '2017-03-01T00:00:00.000Z',
action: 'add',
event_type: 'milestone',
milestone_id: milestoneId,
issue_id: 1,
},
{
created_at: '2017-03-02T00:00:00.000Z',
action: 'remove',
event_type: 'weight',
milestone_id: milestoneId,
issue_id: 1,
weight: 2,
},
{
created_at: '2017-03-03T00:00:00.000Z',
action: 'remove',
event_type: 'milestone',
milestone_id: milestoneId,
issue_id: 1,
},
];
const { burnupScope } = burndownChartData(events).generateBurnupTimeseries({ milestoneId });
expect(burnupScope).toEqual([['2017-03-01', 1], ['2017-03-02', 1], ['2017-03-03', 0]]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import BurnupChart from 'ee/burndown_chart/components/burnup_chart.vue';
describe('Burnup chart', () => {
let wrapper;
const defaultProps = {
startDate: '2019-08-07T00:00:00.000Z',
dueDate: '2019-09-09T00:00:00.000Z',
};
const createComponent = (props = {}) => {
wrapper = shallowMount(BurnupChart, {
propsData: {
...defaultProps,
...props,
},
stubs: {
ResizableChartContainer,
},
});
};
it.each`
scope
${[{ '2019-08-07T00:00:00.000Z': 100 }]}
${[{ '2019-08-07T00:00:00.000Z': 100 }, { '2019-08-08T00:00:00.000Z': 99 }, { '2019-09-08T00:00:00.000Z': 1 }]}
`('renders the lineChart correctly', ({ scope }) => {
createComponent({ scope });
const chartData = wrapper.find(GlLineChart).props('data');
expect(chartData).toEqual([
{
name: 'Total',
data: scope,
},
]);
});
});
......@@ -3506,6 +3506,9 @@ msgstr ""
msgid "BurndownChartLabel|Open issues"
msgstr ""
msgid "Burnup chart"
msgstr ""
msgid "Business"
msgstr ""
......
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