kanbanworkflowstimelinescrumrubyroadmapproject-planningproject-managementopenprojectangularissue-trackerifcgantt-chartganttbug-trackerboardsbcf
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
302 lines
10 KiB
302 lines
10 KiB
class Burndown
|
|
unloadable
|
|
|
|
class Series < Array
|
|
def initialize(*args)
|
|
@unit = args.pop
|
|
@name = args.pop.to_sym
|
|
@display = true
|
|
|
|
raise "Unsupported unit '#{@unit}'" unless [:points, :hours].include? @unit
|
|
super(*args)
|
|
end
|
|
|
|
attr_reader :unit
|
|
attr_reader :name
|
|
attr_accessor :display
|
|
end
|
|
|
|
class SeriesRawData < Hash
|
|
def initialize(*args)
|
|
@collect = args.pop
|
|
@sprint = args.pop
|
|
@project = args.pop
|
|
super(*args)
|
|
end
|
|
|
|
attr_reader :collect
|
|
attr_reader :sprint
|
|
attr_reader :project
|
|
|
|
def collect_names
|
|
@names ||= @collect.to_a.collect(&:last).flatten
|
|
end
|
|
|
|
def out_names
|
|
@out_names ||= ["project_id", "fixed_version_id", "tracker_id", "status_id"]
|
|
end
|
|
|
|
def unit_for(name)
|
|
return :hours if @collect[:hours].include? name
|
|
return :points if @collect[:points].include? name
|
|
end
|
|
|
|
def collect
|
|
days = sprint.days(nil)
|
|
collected_days = days.sort.select{ |d| d <= Date.today }
|
|
|
|
date_hash = {}
|
|
collected_days.each do |date|
|
|
date_hash[date] = 0.0
|
|
end
|
|
|
|
collect_names.each do |c|
|
|
self[c] = date_hash.dup
|
|
end
|
|
|
|
find_interesting_stories.each do |story|
|
|
collect_for_story story, collected_days
|
|
end
|
|
end
|
|
|
|
def collect_for_story(story, collected_days)
|
|
details_by_prop = details_by_property(story)
|
|
|
|
details_by_prop.each do |key, value|
|
|
value.sort_by { |d| d.journal.created_on }
|
|
end
|
|
|
|
current_prop_index = Hash.new { |hash, key| hash[key] = details_by_prop[key] ? 0 : nil }
|
|
|
|
collected_days.each do |date|
|
|
(out_names + collect_names).each do |key|
|
|
|
|
current_prop_index[key] = determine_prop_index(key, date, current_prop_index, details_by_prop)
|
|
|
|
unless not_to_be_collected?(key, date, details_by_prop, current_prop_index, story)
|
|
self[key][date] += value_for_prop(date, details_by_prop[key], current_prop_index[key], story.send(key)).to_f
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
if ActiveRecord::Base.respond_to? :acts_as_journalized
|
|
######################################################
|
|
# New methods using aaj
|
|
#
|
|
|
|
class JournalDetail < ::JournalDetail
|
|
attr_reader :journal
|
|
|
|
def initialize(prop_key, old_value, value, journal = nil)
|
|
super(prop_key, old_value, value)
|
|
@journal = journal
|
|
end
|
|
end
|
|
|
|
def details_by_property(story)
|
|
details = story.journals[1..-1].map do |journal|
|
|
journal.changes.map do |prop_key, change|
|
|
if collect_names.include?(prop_key) || out_names.include?(prop_key)
|
|
JournalDetail.new(prop_key, change.first, change.last, journal)
|
|
end
|
|
end
|
|
end.flatten.compact
|
|
|
|
details.group_by(&:prop_key)
|
|
end
|
|
|
|
def find_interesting_stories
|
|
fixed_version_query = "(issues.fixed_version_id = ? OR journals.changes LIKE '%fixed_version_id: - ? - [0-9]+%' OR journals.changes LIKE '%fixed_version_id: - [0-9]+ - ?%')"
|
|
project_id_query = "(issues.project_id = ? OR journals.changes LIKE '%project_id: - ? - [0-9]+%' OR journals.changes LIKE '%project_id: - [0-9]+ - ?%')"
|
|
|
|
trackers_string = "(#{collected_trackers.map{|i| "(#{i})"}.join("|")})"
|
|
tracker_id_query = "(issues.tracker_id in (?) OR journals.changes LIKE '%tracker_id: - #{trackers_string} - [0-9]+%' OR journals.changes LIKE '%tracker_id: - [0-9]+ - #{trackers_string}%')"
|
|
|
|
stories = Issue.all(:include => :journals,
|
|
:conditions => ["#{ fixed_version_query }" +
|
|
" AND #{ project_id_query }" +
|
|
" AND #{ tracker_id_query }",
|
|
sprint.id, sprint.id, sprint.id,
|
|
project.id, project.id, project.id,
|
|
collected_trackers])
|
|
|
|
stories.delete_if do |s|
|
|
s.fixed_version_id != sprint.id and
|
|
s.journals.none? { |j| j.changes['fixed_version_id'] && j.changes['fixed_version_id'].first == sprint.id }
|
|
end
|
|
|
|
stories.delete_if do |s|
|
|
s.project_id != project.id and
|
|
s.journals.none? { |j| j.changes['project_id'] && j.changes['project_id'].first == project.id }
|
|
end
|
|
|
|
stories.delete_if do |s|
|
|
collected_trackers.include?(s.tracker) and
|
|
s.journals.none? { |j| j.changes['tracker_id'] && collected_trackers.map(&:to_s).include?(j.changes['tracker_id'].first.to_s) }
|
|
end
|
|
|
|
stories
|
|
end
|
|
|
|
else
|
|
######################################################
|
|
# Old methods using old journals
|
|
#
|
|
|
|
def details_by_property(story)
|
|
details = story.journals.sort_by(&:created_on).collect(&:details).flatten.select{ |d| collect_names.include?(d.prop_key) || out_names.include?(d.prop_key)}
|
|
|
|
details.group_by { |d| d.prop_key }
|
|
end
|
|
|
|
def find_interesting_stories
|
|
Issue.find(:all,
|
|
:include => {:journals => :details},
|
|
:conditions => ["(issues.fixed_version_id = ? OR (journal_details.prop_key = 'fixed_version_id' AND (journal_details.old_value = '?' OR journal_details.value = '?'))) " +
|
|
" AND (issues.project_id = ? OR (journal_details.prop_key = 'project_id' AND (journal_details.old_value = '?' OR journal_details.value = '?'))) " +
|
|
" AND (issues.tracker_id in (?) OR (journal_details.prop_key = 'tracker_id' AND (journal_details.old_value in (?) OR journal_details.value in (?))))",
|
|
sprint.id, sprint.id, sprint.id,
|
|
project.id, project.id, project.id,
|
|
collected_trackers, collected_trackers.map(&:to_s), collected_trackers.map(&:to_s)])
|
|
end
|
|
end
|
|
|
|
def collected_trackers
|
|
@collected_trackers ||= Story.trackers << Task.tracker
|
|
end
|
|
|
|
def determine_prop_index(key, date, current_prop_index, details_by_prop)
|
|
prop_index = current_prop_index[key]
|
|
|
|
until prop_index.nil? ||
|
|
details_by_prop[key][prop_index].journal.created_on.to_date > date ||
|
|
prop_index == details_by_prop[key].size - 1
|
|
|
|
prop_index += 1
|
|
end
|
|
|
|
prop_index
|
|
end
|
|
|
|
def not_to_be_collected?(key, date, details_by_prop, current_prop_index, story)
|
|
((collect_names.include?(key) &&
|
|
(project.id != value_for_prop(date, details_by_prop["project_id"], current_prop_index["project_id"], story.send("project_id")).to_i ||
|
|
sprint.id != value_for_prop(date, details_by_prop["fixed_version_id"], current_prop_index["fixed_version_id"], story.send("fixed_version_id")).to_i ||
|
|
!collected_trackers.include?(value_for_prop(date, details_by_prop["tracker_id"], current_prop_index["tracker_id"], story.send("tracker_id")).to_i))) ||
|
|
((key == "story_points") && IssueStatus.find(value_for_prop(date, details_by_prop["status_id"], current_prop_index["status_id"], story.send("status_id"))).is_closed) ||
|
|
((key == "story_points") && IssueStatus.find(value_for_prop(date, details_by_prop["status_id"], current_prop_index["status_id"], story.send("status_id"))).is_done?(project)) ||
|
|
out_names.include?(key) ||
|
|
collected_from_children?(key, story) ||
|
|
story.created_on.to_date > date)
|
|
end
|
|
|
|
def collected_from_children?(key, story)
|
|
key == "remaining_hours" && story.descendants.size > 0
|
|
end
|
|
|
|
def value_for_prop(date, details, index, default)
|
|
if details.nil?
|
|
value = default
|
|
elsif date < details[index].journal.created_on.to_date
|
|
value = details[index].old_value
|
|
else
|
|
value = details[index].value
|
|
end
|
|
|
|
value
|
|
end
|
|
end
|
|
|
|
def initialize(sprint, project, burn_direction = nil)
|
|
burn_direction ||= Setting.plugin_redmine_backlogs[:points_burn_direction]
|
|
|
|
@sprint_id = sprint.id
|
|
|
|
days = make_date_series sprint
|
|
|
|
series_data = SeriesRawData.new(project,
|
|
sprint,
|
|
:hours => ["remaining_hours"],
|
|
:points => ["story_points"])
|
|
|
|
series_data.collect
|
|
|
|
calculate_series series_data
|
|
|
|
determine_max
|
|
end
|
|
|
|
attr_reader :days
|
|
attr_reader :sprint_id
|
|
attr_reader :max
|
|
|
|
attr_reader :remaining_hours
|
|
attr_reader :remaining_hours_ideal
|
|
|
|
attr_reader :story_points
|
|
attr_reader :story_points_ideal
|
|
|
|
def series(select = :active)
|
|
@available_series
|
|
end
|
|
|
|
private
|
|
|
|
def make_date_series sprint
|
|
@days = sprint.days
|
|
end
|
|
|
|
def calculate_series series_data
|
|
series_data.collect_names.each do |c|
|
|
# need to differentiate between hours and sp
|
|
make_series c.to_sym, series_data.unit_for(c), series_data[c].to_a.sort_by{ |a| a.first }.collect(&:last)
|
|
end
|
|
|
|
calculate_ideals(series_data)
|
|
end
|
|
|
|
def calculate_ideals(data)
|
|
(["remaining_hours", "story_points"] & data.collect_names).each do |ideal|
|
|
calculate_ideal(ideal, data.unit_for(ideal))
|
|
end
|
|
end
|
|
|
|
def calculate_ideal(name, unit)
|
|
max = self.send(name).first || 0.0
|
|
delta = max / (self.days.size - 1)
|
|
|
|
ideal = []
|
|
days.each_with_index do |d, i|
|
|
ideal[i] = max - delta * i
|
|
end
|
|
|
|
make_series name.to_s + "_ideal", unit, ideal
|
|
end
|
|
|
|
def make_series(name, units, data)
|
|
@available_series ||= {}
|
|
s = Burndown::Series.new(data, name, units)
|
|
@available_series[name] = s
|
|
instance_variable_set("@#{name}", s)
|
|
end
|
|
|
|
def determine_max
|
|
@max = {
|
|
:points => @available_series.values.select{|s| s.unit == :points}.flatten.compact.max || 0.0,
|
|
:hours => @available_series.values.select{|s| s.unit == :hours}.flatten.compact.max || 0.0
|
|
}
|
|
end
|
|
|
|
def to_h(keys, values)
|
|
return Hash[*keys.zip(values).flatten]
|
|
end
|
|
|
|
def workday_before(date = Date.today)
|
|
d = date - 1
|
|
d = workday_before(d) unless (d.wday > 0 and d.wday < 6) #TODO: make wday configurable
|
|
d
|
|
end
|
|
end
|
|
|