Commit 644abaf2 authored by Jacob Schatz's avatar Jacob Schatz

Merge branch '91-milestone-burndown-charts' into 'master'

Burndown Charts

Closes #91

See merge request !1540
parents 682dbf46 065fa720
import d3 from 'd3';
const margin = { top: 5, right: 65, bottom: 30, left: 50 };
const parseDate = d3.time.format('%Y-%m-%d').parse;
const bisectDate = d3.bisector(d => d.date).left;
const tooltipPadding = { x: 8, y: 3 };
const tooltipDistance = 15;
export default class BurndownChart {
constructor({ container, startDate, dueDate }) {
this.canvas = d3.select(container).append('svg')
.attr('height', '100%')
.attr('width', '100%');
// create svg nodes
this.chartGroup = this.canvas.append('g').attr('class', 'chart');
this.xAxisGroup = this.chartGroup.append('g').attr('class', 'x axis');
this.yAxisGroup = this.chartGroup.append('g').attr('class', 'y axis');
this.idealLinePath = this.chartGroup.append('path').attr('class', 'ideal line');
this.actualLinePath = this.chartGroup.append('path').attr('class', 'actual line');
this.xAxisGroup.append('line').attr('class', 'domain-line');
// create y-axis label
this.label = 'Remaining';
const yAxisLabel = this.yAxisGroup.append('g').attr('class', 'axis-label');
this.yAxisLabelText = yAxisLabel.append('text').text(this.label);
this.yAxisLabelBBox = this.yAxisLabelText.node().getBBox();
this.yAxisLabelLineA = yAxisLabel.append('line');
this.yAxisLabelLineB = yAxisLabel.append('line');
// create chart legend
this.chartLegendGroup = this.chartGroup.append('g').attr('class', 'legend');
this.chartLegendGroup.append('rect');
this.chartLegendIdealKey = this.chartLegendGroup.append('g');
this.chartLegendIdealKey.append('line').attr('class', 'ideal line');
this.chartLegendIdealKey.append('text').text('Guideline');
this.chartLegendIdealKeyBBox = this.chartLegendIdealKey.select('text').node().getBBox();
this.chartLegendActualKey = this.chartLegendGroup.append('g');
this.chartLegendActualKey.append('line').attr('class', 'actual line');
this.chartLegendActualKey.append('text').text('Progress');
this.chartLegendActualKeyBBox = this.chartLegendActualKey.select('text').node().getBBox();
// create tooltips
this.chartFocus = this.chartGroup.append('g').attr('class', 'focus').style('display', 'none');
this.chartFocus.append('circle').attr('r', 4);
this.tooltipGroup = this.chartFocus.append('g').attr('class', 'chart-tooltip');
this.tooltipGroup.append('rect').attr('rx', 3).attr('ry', 3);
this.tooltipGroup.append('text');
this.chartOverlay = this.chartGroup.append('rect').attr('class', 'overlay')
.on('mouseover', () => this.chartFocus.style('display', null))
.on('mouseout', () => this.chartFocus.style('display', 'none'))
.on('mousemove', () => this.handleMousemove());
// parse start and due dates
this.startDate = parseDate(startDate);
this.dueDate = parseDate(dueDate);
// get width and height
const dimensions = this.canvas.node().getBoundingClientRect();
this.width = dimensions.width;
this.height = dimensions.height;
this.chartWidth = this.width - (margin.left + margin.right);
this.chartHeight = this.height - (margin.top + margin.bottom);
// set default scale domains
this.xMax = this.dueDate;
this.yMax = 1;
// create scales
this.xScale = d3.time.scale()
.range([0, this.chartWidth])
.domain([this.startDate, this.xMax]);
this.yScale = d3.scale.linear()
.range([this.chartHeight, 0])
.domain([0, this.yMax]);
// create axes
this.xAxis = d3.svg.axis()
.scale(this.xScale)
.orient('bottom')
.tickFormat(d3.time.format('%b %-d'))
.tickPadding(6)
.tickSize(4, 0);
this.yAxis = d3.svg.axis()
.scale(this.yScale)
.orient('left')
.tickPadding(6)
.tickSize(4, 0);
// create lines
this.line = d3.svg.line()
.x(d => this.xScale(d.date))
.y(d => this.yScale(d.value));
// render the chart
this.scheduleRender();
}
// set data and force re-render
setData(data, { label = 'Remaining', animate } = {}) {
this.data = data.map(datum => ({
date: parseDate(datum[0]),
value: parseInt(datum[1], 10),
})).sort((a, b) => (a.date - b.date));
// adjust axis domain to correspond with data
this.xMax = Math.max(d3.max(this.data, d => d.date) || 0, this.dueDate);
this.yMax = d3.max(this.data, d => d.value) || 1;
this.xScale.domain([this.startDate, this.xMax]);
this.yScale.domain([0, this.yMax]);
// calculate the bounding box for the axis label if updated
// (this must be done here to prevent layout thrashing)
if (this.label !== label) {
this.label = label;
this.yAxisLabelBBox = this.yAxisLabelText.text(label).node().getBBox();
}
// set ideal line data
if (this.data.length > 1) {
const idealStart = this.data[0] || { date: this.startDate, value: 0 };
const idealEnd = { date: this.dueDate, value: 0 };
this.idealData = [idealStart, idealEnd];
}
this.scheduleLineAnimation = !!animate;
this.scheduleRender();
}
handleMousemove() {
if (!this.data) return;
const mouseOffsetX = d3.mouse(this.chartOverlay.node())[0];
const dateOffset = this.xScale.invert(mouseOffsetX);
const i = bisectDate(this.data, dateOffset, 1);
const d0 = this.data[i - 1];
const d1 = this.data[i];
if (d1 == null || dateOffset - d0.date < d1.date - dateOffset) {
this.renderTooltip(d0);
} else {
this.renderTooltip(d1);
}
}
// reset width and height to match the svg element, then re-render if necessary
handleResize() {
const dimensions = this.canvas.node().getBoundingClientRect();
if (this.width !== dimensions.width || this.height !== dimensions.height) {
this.width = dimensions.width;
this.height = dimensions.height;
// adjust axis range to correspond with chart size
this.chartWidth = this.width - (margin.left + margin.right);
this.chartHeight = this.height - (margin.top + margin.bottom);
this.xScale.range([0, this.chartWidth]);
this.yScale.range([this.chartHeight, 0]);
this.scheduleRender();
}
}
scheduleRender() {
if (this.queuedRender == null) {
this.queuedRender = requestAnimationFrame(() => this.render());
}
}
render() {
this.queuedRender = null;
this.renderedTooltipPoint = null; // force tooltip re-render
this.xAxis.ticks(Math.floor(this.chartWidth / 120));
this.yAxis.ticks(Math.min(Math.floor(this.chartHeight / 60), this.yMax));
this.chartGroup.attr('transform', `translate(${margin.left}, ${margin.top})`);
this.xAxisGroup.attr('transform', `translate(0, ${this.chartHeight})`);
this.xAxisGroup.call(this.xAxis);
this.yAxisGroup.call(this.yAxis);
// replace x-axis line with one which continues into the right margin
this.xAxisGroup.select('.domain').remove();
this.xAxisGroup.select('.domain-line').attr('x1', 0).attr('x2', this.chartWidth + margin.right);
// update y-axis label
const axisLabelOffset = (this.yAxisLabelBBox.height / 2) - margin.left;
const axisLabelPadding = (this.chartHeight - this.yAxisLabelBBox.width - 10) / 2;
this.yAxisLabelText
.attr('y', 0 - margin.left)
.attr('x', 0 - (this.chartHeight / 2))
.attr('dy', '1em')
.style('text-anchor', 'middle')
.attr('transform', 'rotate(-90)');
this.yAxisLabelLineA
.attr('x1', axisLabelOffset)
.attr('x2', axisLabelOffset)
.attr('y1', 0)
.attr('y2', axisLabelPadding);
this.yAxisLabelLineB
.attr('x1', axisLabelOffset)
.attr('x2', axisLabelOffset)
.attr('y1', this.chartHeight - axisLabelPadding)
.attr('y2', this.chartHeight);
// update legend
const legendPadding = 10;
const legendSpacing = 5;
const idealBBox = this.chartLegendIdealKeyBBox;
const actualBBox = this.chartLegendActualKeyBBox;
const keyWidth = Math.ceil(Math.max(idealBBox.width, actualBBox.width));
const keyHeight = Math.ceil(Math.max(idealBBox.height, actualBBox.height));
const idealKeyOffset = legendPadding;
const actualKeyOffset = legendPadding + keyHeight + legendSpacing;
const legendWidth = (legendPadding * 2) + 24 + keyWidth;
const legendHeight = (legendPadding * 2) + (keyHeight * 2) + legendSpacing;
const legendOffset = (this.chartWidth + margin.right) - legendWidth - 1;
this.chartLegendGroup.select('rect')
.attr('width', legendWidth)
.attr('height', legendHeight);
this.chartLegendGroup.selectAll('text')
.attr('x', 24)
.attr('dy', '1em');
this.chartLegendGroup.selectAll('line')
.attr('y1', keyHeight / 2)
.attr('y2', keyHeight / 2)
.attr('x1', 0)
.attr('x2', 18);
this.chartLegendGroup.attr('transform', `translate(${legendOffset}, 0)`);
this.chartLegendIdealKey.attr('transform', `translate(${legendPadding}, ${idealKeyOffset})`);
this.chartLegendActualKey.attr('transform', `translate(${legendPadding}, ${actualKeyOffset})`);
// update overlay
this.chartOverlay
.attr('fill', 'none')
.attr('pointer-events', 'all')
.attr('width', this.chartWidth)
.attr('height', this.chartHeight);
// render lines if data available
if (this.data != null && this.data.length > 1) {
this.actualLinePath.datum(this.data).attr('d', this.line);
this.idealLinePath.datum(this.idealData).attr('d', this.line);
if (this.scheduleLineAnimation === true) {
this.scheduleLineAnimation = false;
// hide tooltips until animation is finished
this.chartFocus.attr('opacity', 0);
this.constructor.animateLinePath(this.actualLinePath, 800, () => {
this.chartFocus.attr('opacity', null);
});
}
}
}
renderTooltip(datum) {
if (this.renderedTooltipPoint === datum) return;
this.renderedTooltipPoint = datum;
// generate tooltip content
const format = d3.time.format('%b %-d, %Y');
const tooltip = `${datum.value} ${this.label} / ${format(datum.date)}`;
// move the tooltip point of origin to the point on the graph
const x = this.xScale(datum.date);
const y = this.yScale(datum.value);
const textSize = this.tooltipGroup.select('text').text(tooltip).node().getBBox();
const width = textSize.width + (tooltipPadding.x * 2);
const height = textSize.height + (tooltipPadding.y * 2);
// calculate bounraries
const xMin = 0 - x - margin.left;
const yMin = 0 - y - margin.top;
const xMax = (this.chartWidth + margin.right) - x - width;
const yMax = (this.chartHeight + margin.bottom) - y - height;
// try to fit tooltip above point
let xOffset = 0 - Math.floor(width / 2);
let yOffset = 0 - tooltipDistance - height;
if (yOffset <= yMin) {
// else try to fit tooltip to the right
xOffset = tooltipDistance;
yOffset = 0 - Math.floor(height / 2);
if (xOffset >= xMax) {
// else place tooltip on the left
xOffset = 0 - tooltipDistance - width;
}
}
// ensure coordinates keep the entire tooltip in-bounds
xOffset = Math.max(xMin, Math.min(xMax, xOffset));
yOffset = Math.max(yMin, Math.min(yMax, yOffset));
// move everything into place
this.chartFocus.attr('transform', `translate(${x}, ${y})`);
this.tooltipGroup.attr('transform', `translate(${xOffset}, ${yOffset})`);
this.tooltipGroup.select('text')
.attr('dy', '1em')
.attr('x', tooltipPadding.x)
.attr('y', tooltipPadding.y);
this.tooltipGroup.select('rect')
.attr('width', width)
.attr('height', height);
}
animateResize(seconds = 5) {
this.ticksLeft = this.ticksLeft || 0;
if (this.ticksLeft <= 0) {
const interval = setInterval(() => {
this.ticksLeft -= 1;
if (this.ticksLeft <= 0) {
clearInterval(interval);
}
this.handleResize();
}, 20);
}
this.ticksLeft = seconds * 50;
}
static animateLinePath(path, duration = 1000, cb) {
// hack to run a callback at transition end
function after(transition, callback) {
let i = 0;
transition
.each(() => (i += 1))
.each('end', function end(...args) {
i -= 1;
if (i === 0) {
callback.apply(this, args);
}
});
}
const lineLength = path.node().getTotalLength();
path
.attr('stroke-dasharray', `${lineLength} ${lineLength}`)
.attr('stroke-dashoffset', lineLength)
.transition()
.duration(duration)
.ease('linear')
.attr('stroke-dashoffset', 0)
.call(after, () => {
path.attr('stroke-dasharray', null);
if (cb) cb();
});
}
}
import Cookies from 'js-cookie';
import BurndownChart from './burndown_chart';
$(() => {
// handle hint dismissal
const hint = $('.burndown-hint');
hint.on('click', '.dismiss-icon', () => {
hint.hide();
Cookies.set('hide_burndown_message', 'true');
});
// generate burndown chart (if data available)
const container = '.burndown-chart';
const $chartElm = $(container);
if ($chartElm.length) {
const startDate = $chartElm.data('startDate');
const dueDate = $chartElm.data('dueDate');
const chartData = $chartElm.data('chartData');
const openIssuesCount = chartData.map(d => [d[0], d[1]]);
const openIssuesWeight = chartData.map(d => [d[0], d[2]]);
const chart = new BurndownChart({ container, startDate, dueDate });
let currentView = 'count';
chart.setData(openIssuesCount, { label: 'Open issues', animate: true });
$('.js-burndown-data-selector').on('click', 'button', function switchData() {
const $this = $(this);
const show = $this.data('show');
if (currentView !== show) {
currentView = show;
$this.addClass('active').siblings().removeClass('active');
switch (show) {
case 'count':
chart.setData(openIssuesCount, { label: 'Open issues', animate: true });
break;
case 'weight':
chart.setData(openIssuesWeight, { label: 'Open issue weight', animate: true });
break;
default:
break;
}
}
});
window.addEventListener('resize', () => chart.animateResize(1));
$(document).on('click', '.js-sidebar-toggle', () => chart.animateResize(2));
}
});
...@@ -200,3 +200,157 @@ ...@@ -200,3 +200,157 @@
cursor: -webkit-grab; cursor: -webkit-grab;
cursor: grab; cursor: grab;
} }
// EE-only
.burndown-hint.container-fluid {
border: 1px solid $border-color;
border-radius: $border-radius-default;
position: relative;
margin: $gl-padding 0;
overflow: hidden;
padding-top: 15px;
padding-bottom: 15px;
.dismiss-icon {
position: absolute;
right: $gl-padding;
cursor: pointer;
color: $cycle-analytics-dismiss-icon-color;
z-index: 1;
}
.svg-container {
text-align: center;
svg {
max-width: 200px;
max-height: 200px;
}
}
.inner-content {
@media (max-width: $screen-xs-max) {
padding: 0 28px;
text-align: center;
}
h4 {
color: $gl-text-color;
font-size: 17px;
}
p {
color: $cycle-analytics-box-text-color;
margin-bottom: $gl-padding;
}
}
}
.burndown-header {
margin: 24px 0 12px;
h3 {
font-size: 16px;
margin: 0;
}
.btn-group {
margin-left: 20px;
margin-bottom: 2px;
}
.btn {
font-size: 12px;
@include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
&.active {
background-color: $blue-500;
border-color: $blue-600;
color: $white-light;
}
}
}
.burndown-chart {
width: 100%;
height: 380px;
margin: 5px 0;
@media (max-width: $screen-sm-max) {
height: 320px;
}
@media (max-width: $screen-xs-max) {
height: 200px;
}
.axis {
font-size: 12px;
line,
path {
fill: none;
stroke: $stat-graph-axis-fill;
shape-rendering: crispEdges;
}
}
.axis-label {
text {
fill: $gl-text-color-secondary;
}
line {
stroke: $border-color;
}
}
.legend {
shape-rendering: crispEdges;
text {
font-size: 13px;
fill: $gl-text-color-disabled;
}
rect {
stroke: $border-color;
fill: none;
}
}
.line {
stroke-width: 2px;
fill: none;
&.actual {
stroke: $gl-success;
}
&.ideal {
stroke: $stat-graph-axis-fill;
stroke-dasharray: 6px 6px;
}
}
.focus {
circle {
fill: $white-light;
stroke: $gl-success;
stroke-width: 2px;
}
}
.chart-tooltip {
text {
font-size: 12px;
fill: $white-light;
}
rect {
fill: $black;
}
}
}
...@@ -42,6 +42,7 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -42,6 +42,7 @@ class Projects::MilestonesController < Projects::ApplicationController
end end
def show def show
@burndown = Burndown.new(@milestone)
end end
def create def create
......
class Burndown
attr_accessor :start_date, :due_date, :end_date, :issues_count, :issues_weight
def initialize(milestone)
@milestone = milestone
@start_date = @milestone.start_date
@due_date = @milestone.due_date
@end_date = @milestone.due_date
@end_date = Date.today if @end_date.present? && @end_date > Date.today
@issues_count, @issues_weight = milestone.issues.reorder(nil).pluck('COUNT(*), COALESCE(SUM(weight), 0)').first
end
# Returns the chart data in the following format:
# [date, issue count, issue weight] eg: [["2017-03-01", 33, 127], ["2017-03-02", 35, 73], ["2017-03-03", 28, 50]...]
def as_json(opts = nil)
return [] unless valid?
open_issues_count = issues_count
open_issues_weight = issues_weight
start_date.upto(end_date).each_with_object([]) do |date, chart_data|
closed, reopened = closed_and_reopened_issues_by(date)
closed_issues_count = closed.count
closed_issues_weight = sum_issues_weight(closed)
open_issues_count -= closed_issues_count
open_issues_weight -= closed_issues_weight
chart_data << [date.strftime("%Y-%m-%d"), open_issues_count, open_issues_weight]
reopened_count = reopened.count
reopened_weight = sum_issues_weight(reopened)
open_issues_count += reopened_count
open_issues_weight += reopened_weight
end
end
def valid?
start_date && due_date
end
private
def sum_issues_weight(issues)
issues.map(&:weight).compact.sum
end
def closed_and_reopened_issues_by(date)
current_date = date.to_date
closed = issues_with_closed_at.select { |issue| issue.closed_at.to_date == current_date }
reopened = closed.select { |issue| issue.state == 'reopened' }
[closed, reopened]
end
def issues_with_closed_at
@issues_with_closed_at ||=
@milestone.issues.select('closed_at, weight, state').
where('closed_at IS NOT NULL').
order('closed_at ASC')
end
end
...@@ -67,10 +67,6 @@ class Issue < ActiveRecord::Base ...@@ -67,10 +67,6 @@ class Issue < ActiveRecord::Base
before_transition any => :closed do |issue| before_transition any => :closed do |issue|
issue.closed_at = Time.zone.now issue.closed_at = Time.zone.now
end end
before_transition closed: any do |issue|
issue.closed_at = nil
end
end end
def hook_attrs def hook_attrs
......
...@@ -46,6 +46,8 @@ ...@@ -46,6 +46,8 @@
= preserve do = preserve do
= markdown_field(@milestone, :description) = markdown_field(@milestone, :description)
= render 'shared/milestones/burndown', milestone: @milestone, project: @project, burndown: @burndown
- if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero?
.alert.alert-success.prepend-top-default .alert.alert-success.prepend-top-default
%span Assign some issues to this milestone. %span Assign some issues to this milestone.
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 158"><g fill="none" fill-rule="evenodd" transform="translate(-48-26)"><path fill="#fff" d="m25 28h240v158h-240z"/><g transform="translate(56 37)"><g transform="translate(0 21)"><path fill="#eee" d="m13.591 92.21l3.03-3.979c.669-.879.499-2.134-.38-2.803-.879-.669-2.134-.499-2.803.38l-3.03 3.979c-.669.879-.499 2.134.38 2.803.879.669 2.134.499 2.803-.38m10.902-14.323l3.03-3.979c.669-.879.499-2.134-.38-2.803-.879-.669-2.134-.499-2.803.38l-3.03 3.979c-.669.879-.499 2.134.38 2.803.879.669 2.134.499 2.803-.38m10.902-14.323l3.03-3.979c.669-.879.499-2.134-.38-2.803-.879-.669-2.134-.499-2.803.38l-3.03 3.979c-.669.879-.499 2.134.38 2.803.879.669 2.134.499 2.803-.38m10.902-14.323l3.03-3.979c.669-.879.499-2.134-.38-2.803-.879-.669-2.134-.499-2.803.38l-3.03 3.979c-.669.879-.499 2.134.38 2.803.879.669 2.134.499 2.803-.38m10.902-14.323l3.03-3.979c.669-.879.499-2.134-.38-2.803-.879-.669-2.134-.499-2.803.38l-3.03 3.979c-.669.879-.499 2.134.38 2.803.879.669 2.134.499 2.803-.38m7.167-4.942l2.644 4.244c.584.938 1.818 1.224 2.755.64.938-.584 1.224-1.818.64-2.755l-2.644-4.244c-.584-.938-1.818-1.224-2.755-.64-.938.584-1.224 1.818-.64 2.755m9.517 15.278l2.644 4.244c.584.938 1.818 1.224 2.755.64.938-.584 1.224-1.818.64-2.755l-2.644-4.244c-.584-.938-1.818-1.224-2.755-.64-.938.584-1.224 1.818-.64 2.755m9.517 15.278l2.644 4.244c.584.938 1.818 1.224 2.755.64.938-.584 1.224-1.818.64-2.755l-2.644-4.244c-.584-.938-1.818-1.224-2.755-.64-.938.584-1.224 1.818-.64 2.755m9.517 15.278l2.644 4.244c.584.938 1.818 1.224 2.755.64.938-.584 1.224-1.818.64-2.755l-2.644-4.244c-.584-.938-1.818-1.224-2.755-.64-.938.584-1.224 1.818-.64 2.755m13.423 5.929l3.213-3.831c.71-.846.599-2.108-.247-2.818-.846-.71-2.108-.599-2.818.247l-3.213 3.831c-.71.846-.599 2.108.247 2.818.846.71 2.108.599 2.818-.247m11.567-13.791l3.213-3.831c.71-.846.599-2.108-.247-2.818-.846-.71-2.108-.599-2.818.247l-3.213 3.831c-.71.846-.599 2.108.247 2.818.846.71 2.108.599 2.818-.247m8.253-11.614l2.794 4.146c.617.916 1.86 1.158 2.776.541.916-.617 1.158-1.86.541-2.776l-2.794-4.146c-.617-.916-1.86-1.158-2.776-.541-.916.617-1.158 1.86-.541 2.776m10.06 14.927l2.794 4.146c.617.916 1.86 1.158 2.776.541.916-.617 1.158-1.86.541-2.776l-2.794-4.146c-.617-.916-1.86-1.158-2.776-.541-.916.617-1.158 1.86-.541 2.776m10.06 14.927l2.794 4.146c.617.916 1.86 1.158 2.776.541.916-.617 1.158-1.86.541-2.776l-2.794-4.146c-.617-.916-1.86-1.158-2.776-.541-.916.617-1.158 1.86-.541 2.776m10.06 14.927c.618.917 1.861 1.159 2.777.541.916-.617 1.158-1.86.541-2.776-.618-.917-1.861-1.159-2.777-.541-.916.617-1.158 1.86-.541 2.776"/><g transform="translate(61)"><rect width="3" height="24" fill="#fde5d8" rx="1.5"/><path fill="#fc6d26" d="m3 13v-11l11.533 3.105c1.387.373 1.478 1.207.192 1.868l-11.724 6.03"/></g><path fill="#b5a7dd" d="m166.93 83.6l-13.994-7.365c-.287-.151-.607-.23-.931-.23h-20.485c-.491-.271-.816-.45-4.08-2.251l-24.469-13.5c-.694-.383-1.548-.32-2.178.16l-19.433 14.806-20.783-26.451c-.65-.827-1.83-1.01-2.699-.417l-20.604 14.05-20.904-21.708c-.614 1.222-1.633 2.206-2.881 2.775l22.08 22.924c.677.703 1.761.815 2.567.265l20.456-13.947 20.845 26.53c.675.859 1.915 1.018 2.785.355l19.963-15.21c-.005-.003.678.374 3.39 1.87l24.469 13.5c.296.163.628.249.966.249h20.506l13.556 7.135c.201-1.391.879-2.628 1.864-3.539"/><path fill="#6b4fbb" d="m171 96c4.418 0 8-3.582 8-8 0-4.418-3.582-8-8-8-4.418 0-8 3.582-8 8 0 4.418 3.582 8 8 8m0-4c-2.209 0-4-1.791-4-4 0-2.209 1.791-4 4-4 2.209 0 4 1.791 4 4 0 2.209-1.791 4-4 4m-160-46c4.418 0 8-3.582 8-8 0-4.418-3.582-8-8-8-4.418 0-8 3.582-8 8 0 4.418 3.582 8 8 8m0-4c-2.209 0-4-1.791-4-4 0-2.209 1.791-4 4-4 2.209 0 4 1.791 4 4 0 2.209-1.791 4-4 4"/></g><path fill="#fde5d8" d="m168.78 39.803l-2.908.646c-.542.12-.882-.228-.763-.763l.646-2.908-.646-2.908c-.12-.542.228-.882.763-.763l2.908.646 2.908-.646c.542-.12.882.228.763.763l-.646 2.908.646 2.908c.12.542-.228.882-.763.763l-2.908-.646" transform="matrix(.70711.70711-.70711.70711 75.44-108.57)"/><path fill="#d4cde8" d="m101.36 53.839l-2.21.491c-.537.119-.874-.226-.756-.756l.491-2.21-.491-2.21c-.119-.537.226-.874.756-.756l2.21.491 2.21-.491c.537-.119.874.226.756.756l-.491 2.21.491 2.21c.119.537-.226.874-.756.756l-2.21-.491" transform="matrix(.70711.70711-.70711.70711 66.01-56.631)"/><g fill="#fde5d8"><path d="m125.36 8.839l-2.21.491c-.537.119-.874-.226-.756-.756l.491-2.21-.491-2.21c-.119-.537.226-.874.756-.756l2.21.491 2.21-.491c.537-.119.874.226.756.756l-.491 2.21.491 2.21c.119.537-.226.874-.756.756l-2.21-.491" transform="matrix(.70711.70711-.70711.70711 41.22-86.78)"/><path d="m18.778 23.803l-2.908.646c-.542.12-.882-.228-.763-.763l.646-2.908-.646-2.908c-.12-.542.228-.882.763-.763l2.908.646 2.908-.646c.542-.12.882.228.763.763l-.646 2.908.646 2.908c.12.542-.228.882-.763.763l-2.908-.646" transform="matrix(.70711.70711-.70711.70711 20.19-7.192)"/></g></g></g></svg>
- milestone = local_assigns[:milestone]
- project = local_assigns[:project]
- burndown = local_assigns[:burndown]
- can_generate_chart = burndown&.valid?
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_d3')
= page_specific_javascript_bundle_tag('burndown_chart')
- if can_generate_chart
.burndown-header
%h3
Burndown chart
.btn-group.js-burndown-data-selector
%button.btn.btn-xs.active{ data: { show: 'count' } }
Issues
%button.btn.btn-xs{ data: { show: 'weight' } }
Issue weight
.burndown-chart{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"), due_date: burndown.due_date.strftime("%Y-%m-%d"), chart_data: burndown.to_json } }
- elsif can?(current_user, :admin_milestone, @project) && cookies['hide_burndown_message'].nil?
.burndown-hint.content-block.container-fluid
= icon("times", class: "dismiss-icon")
.row
.col-sm-4.col-xs-12.svg-container
= custom_icon('icon_burndown_chart_splash')
.col-sm-8.col-xs-12.inner-content
%h4
Burndown chart
%p
View your milestone's progress as a burndown chart. Add both a start and a due date to
this milestone and the chart will appear here, always up-to-date.
= link_to "Add start and due date", edit_namespace_project_milestone_path(project.namespace, project, milestone), class: 'btn'
---
title: Add burndown chart to milestones
merge_request:
author:
...@@ -23,6 +23,7 @@ var config = { ...@@ -23,6 +23,7 @@ var config = {
main: './main.js', main: './main.js',
blob: './blob_edit/blob_bundle.js', blob: './blob_edit/blob_bundle.js',
boards: './boards/boards_bundle.js', boards: './boards/boards_bundle.js',
burndown_chart: './burndown_chart/index.js',
cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js',
commit_pipelines: './commit/pipelines/pipelines_bundle.js', commit_pipelines: './commit/pipelines/pipelines_bundle.js',
diff_notes: './diff_notes/diff_notes_bundle.js', diff_notes: './diff_notes/diff_notes_bundle.js',
...@@ -123,6 +124,7 @@ var config = { ...@@ -123,6 +124,7 @@ var config = {
'graphs', 'graphs',
'users', 'users',
'monitoring', 'monitoring',
'burndown_chart',
], ],
}), }),
......
require './spec/support/sidekiq'
require './spec/support/test_env'
class Gitlab::Seeder::Burndown
def initialize(project, perf: false)
@project = project
end
def seed!
Timecop.travel 10.days.ago
Sidekiq::Testing.inline! do
create_milestone
puts '.'
create_issues
puts '.'
close_issues
puts '.'
reopen_issues
puts '.'
end
Timecop.return
print '.'
end
private
def create_milestone
milestone_params = {
title: "Sprint - #{FFaker::Lorem.sentence}",
description: FFaker::Lorem.sentence,
state: 'active',
start_date: Date.today,
due_date: rand(5..10).days.from_now
}
@milestone = Milestones::CreateService.new(@project, @project.team.users.sample, milestone_params).execute
end
def create_issues
20.times do
issue_params = {
title: FFaker::Lorem.sentence(6),
description: FFaker::Lorem.sentence,
state: 'opened',
milestone: @milestone,
assignee: @project.team.users.sample,
weight: rand(1..9)
}
Issues::CreateService.new(@project, @project.team.users.sample, issue_params).execute
end
end
def close_issues
@milestone.start_date.upto(@milestone.due_date) do |date|
Timecop.travel(date)
close_number = rand(0..2)
open_issues = @milestone.issues.opened
open_issues = open_issues.slice(0..close_number)
open_issues.each do |issue|
Issues::CloseService.new(@project, @project.team.users.sample, {}).execute(issue)
end
end
Timecop.return
end
def reopen_issues
count = @milestone.issues.closed.count / 3
issues = @milestone.issues.closed.slice(0..rand(count))
issues.each { |i| i.update(state: 'reopened') }
end
end
Gitlab::Seeder.quiet do
if project_id = ENV['PROJECT_ID']
project = Project.find(project_id)
seeder = Gitlab::Seeder::Burndown.new(project)
seeder.seed!
else
Project.all.each do |project|
seeder = Gitlab::Seeder::Burndown.new(project)
seeder.seed!
end
end
end
require 'spec_helper'
describe Burndown, models: true do
let(:start_date) { "2017-03-01" }
let(:due_date) { "2017-03-05" }
let(:milestone) { create(:milestone, start_date: start_date, due_date: due_date) }
let(:project) { milestone.project }
let(:user) { create(:user) }
let(:issue_params) do
{
title: FFaker::Lorem.sentence(6),
description: FFaker::Lorem.sentence,
state: 'opened',
milestone: milestone,
weight: 2,
project_id: project.id
}
end
before do
project.add_master(user)
build_sample
end
after do
Timecop.return
end
subject { described_class.new(milestone).to_json }
it "generates an array with date, issue count and weight" do
expect(subject).to eq([
["2017-03-01", 33, 66],
["2017-03-02", 35, 70],
["2017-03-03", 28, 56],
["2017-03-04", 32, 64],
["2017-03-05", 21, 42]
].to_json)
end
it "returns empty array if milestone start date is nil" do
milestone.update(start_date: nil)
expect(subject).to eq([].to_json)
end
it "returns empty array if milestone due date is nil" do
milestone.update(due_date: nil)
expect(subject).to eq([].to_json)
end
it "it counts until today if milestone due date > Date.today" do
Timecop.travel(milestone.due_date - 1.day)
expect(JSON.parse(subject).last[0]).to eq(Time.now.strftime("%Y-%m-%d"))
end
# Creates, closes and reopens issues only for odd days numbers
def build_sample
milestone.start_date.upto(milestone.due_date) do |date|
day = date.day
next if day.even?
count = day * 4
Timecop.travel(date)
# Create issues
issues = create_list(:issue, count, issue_params)
# Close issues
closed = issues.slice(0..count / 2)
closed.each(&:close)
# Reopen issues
closed.slice(0..count / 4).each(&:reopen)
end
Timecop.travel(due_date)
end
end
...@@ -51,14 +51,6 @@ describe Issue, models: true do ...@@ -51,14 +51,6 @@ describe Issue, models: true do
expect(issue.closed_at).to eq(now) expect(issue.closed_at).to eq(now)
end end
it 'sets closed_at to nil when issue is reopened' do
issue = create(:issue, state: 'closed')
issue.reopen
expect(issue.closed_at).to be_nil
end
end end
describe '#to_reference' do describe '#to_reference' do
......
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