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
This diff is collapsed.
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