Commit b281d49c authored by Simon Knox's avatar Simon Knox

Add burnup chart for tracking milestone scope

Feature flag disabled by default for now
Uses same license as burndown charts
Mostly copy things from burndown charts, with some slightly
different interals
parent 7c9f1195
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' },
];
describe('generateBurndownTimeseries', () => {
let burndownChartData;
beforeEach(() => {
burndownChartData = new BurndownChartData(milestoneEvents, startDate, dueDate);
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,
},
]);
});
});
......@@ -3503,6 +3503,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