Simplified statistics

pull/6827/head
friflaj 14 years ago
parent 9fea1402cb
commit 3ec41d99ba
  1. 2
      app/controllers/backlogs_global_controller.rb
  2. 3
      app/models/sprint.rb
  3. 10
      app/models/story.rb
  4. 29
      app/views/backlogs_global/statistics.rhtml
  5. 20
      config/locales/en.yml
  6. 78
      config/locales/nl.yml
  7. 309
      lib/backlogs_project_patch.rb
  8. 3
      lib/tasks/update_translations.rake

@ -8,7 +8,7 @@ class BacklogsGlobalController < ApplicationController
:conditions => ["enabled_modules.name = 'backlogs' and status = ?", Project::STATUS_ACTIVE],
:include => :project,
:joins => :project).collect { |mod| mod.project }
@projects.sort! {|a, b| a.scrum_statistics[:score][:score] <=> b.scrum_statistics[:score][:score]}
@projects.sort! {|a, b| a.scrum_statistics.score <=> b.scrum_statistics.score}
render :action => 'statistics'
end

@ -258,7 +258,8 @@ class Sprint < Version
def burndown(burn_direction = nil)
return nil if not self.has_burndown
return Burndown.new(self, burn_direction)
@cached_burndown ||= Burndown.new(self, burn_direction)
return @cached_burndown
end
def self.generate_burndown(only_current = true)

@ -1,15 +1,15 @@
class Story < Issue
unloadable
named_scope :product_backlog, lambda { |project|
{
def self.product_backlog(project, limit=nil)
return Story.find(:all,
:order => 'position ASC',
:conditions => [
"parent_id is NULL and project_id = ? and tracker_id in (?) and fixed_version_id is NULL", #and status_id in (?)",
project.id, Story.trackers #, IssueStatus.find(:all, :conditions => ["is_closed = ?", false]).collect {|s| "#{s.id}" }
]
}
}
],
:limit => limit)
end
named_scope :sprint_backlog, lambda { |sprint|
{

@ -13,33 +13,38 @@
<div id="projects">
<% @projects.each do |project| %>
<% stats = project.scrum_statistics %>
<h3><a href="#"><span class="score score_<%= stats[:score][:score] %>"><%= stats[:score][:score] %></span> <%= project.name %></a> </h3>
<%
stats = project.scrum_statistics
scores = stats.scores
errors = stats.errors('backlogs_')
info = stats.info
%>
<h3><a href="#"><span class="score score_<%= stats.score %>"><%= stats.score %></span> <%= project.name %></a> </h3>
<div>
<% if stats[:velocity] %>
<%= l(:label_sprint_velocity, { :velocity => stats[:velocity], :sprints => stats[:sprints].length, :days => stats[:average_days_per_sprint]}) %><br/>
<% if !scores[:velocity_missing] %>
<%= l(:label_sprint_velocity, { :velocity => info[:velocity], :sprints => info[:closed_sprints].length, :days => info[:average_days_per_sprint]}) %><br/>
<% end %>
<h4><%= l(:backlogs_product_backlog) %></h4>
<%= link_to(l(:backlogs_product_backlog), { :controller => 'backlogs', :action => 'select_issues', :project_id => project }) %><br/>
<% if stats[:active_sprint] || stats[:sprints] %>
<% if info[:active_sprint] || info[:closed_sprints] %>
<h4><%= l(:backlogs_sprints) %></h4>
<% if stats[:active_sprint] %>
<%= link_to(stats[:active_sprint].name, {
<% if info[:active_sprint] %>
<%= link_to(info[:active_sprint].name, {
:controller => 'backlogs',
:action => 'select_issues',
:project_id => project,
:sprint_id => stats[:active_sprint].id }) %> (<%= l(:backlogs_active) %>)<br/>
:sprint_id => info[:active_sprint].id }) %> (<%= l(:backlogs_active) %>)<br/>
<% end %>
<% if stats[:sprints] %>
<% stats[:sprints].each do |sprint| %>
<% if info[:closed_sprints] %>
<% info[:closed_sprints].each do |sprint| %>
<%= link_to(sprint.name, { :controller => 'backlogs', :action => 'select_issues', :project_id => project, :sprint_id => sprint.id }) %><br/>
<% end %>
<% end %>
<% end %>
<% stats[:score][:errors].each do |e| %>
<% errors.each do |e| %>
<ul>
<li><%= e %></li>
<li><%= l(e) %></li>
</ul>
<% end %>
</div>

@ -28,19 +28,6 @@ en:
remaining_hours: "remaining hours"
field_velocity_based_estimate: "Velocity based estimate"
product_backlog_empty: "Product backlog is empty"
product_backlog_unsized: "Top of the product backlog has unsized stories"
unsized_stories: "The last {{sprints}} sprints have unsized stories"
active_sprint_unsized_stories: "The active sprint has unsized stories"
unestimated_tasks: "The last {{sprints}} sprints have unestimated tasks"
active_sprint_unestimated_tasks: "The active sprint has unestimated tasks"
size_accuracy: "Story size estimates vary by {{pct}}%"
sprint_notes_missing: "Last sprint doesn't have sprint notes"
project_dormant: "Project shows no activity"
active_sprint_dormant: "Active sprint shows no activity"
no_velocity: "Project has no velocity"
optimistic_velocity: "Project plans too optimistically by {{pct}}%"
pessimistic_velocity: "Project plans too pessimistically by {{pct}}%"
backlogs_active: active
label_task_board: "Task board"
@ -59,6 +46,13 @@ en:
label_product_backlog: "product backlog"
label_Product_backlog: "Product backlog"
backlogs_sizing_inconsistent: "Story sizes vary against their estimates"
backlogs_velocity_missing: "No velocity could be calculated for this project"
backlogs_product_backlog_is_empty: "Product backlog is empty"
backlogs_inactive: "Project shows no activity"
backlogs_sprint_unestimated: "Closed or active sprints with unestimated stories"
backlogs_sprint_notes_missing: "Closed sprints without retrospective/review notes"
label_hours: hours
label_points: points
label_not_prioritized: "not prioritized"

@ -1,65 +1,71 @@
---
nl:
backlogs_sprint_unestimated: "Gesloten of actieve sprints bevatten stories zonder tijdschattingen"
error_outro: Verbeter a.u.b de eerdergenoemde problemen voordat u nogmaals verzend.
backlogs_card_specification: Label type voor kaartjes
unestimated_tasks: De laatste {{sprints}} sprints hebben taken zonder urenschatting
label_select_all: Selecteer alles
label_sprint_impediments: Sprint Impediments
points_resolved: points opgelost
label_points_burn_down: Omlaag
unsized_stories: De laatste {{sprints}} sprints hebben stories zonder sizes
active_sprint_dormant: Lopende sprint vertoont geen activiteit
label_task_board: Taakbord
button_edit_wiki: Wijzig wiki page
backlogs_points_burn_direction: Points burn omhoog/omlaag
backlogs_velocity_missing: "De velocity voor dit project kan niet worden berekend"
blank: is leeg
backlogs_wiki_template: Template voor sprint wiki pagina
required_burn_rate_hours: benodigde burn rate (hours)
backlogs_points_burn_direction: Points burn omhoog/omlaag
label_printable_cards: Print kaarten
field_remaining_hours: Resterende uren
size_accuracy: Story size estimates vary by {{pct}}%
required_burn_rate_hours: benodigde burn rate (hours)
button_edit_wiki: Wijzig wiki page
error_intro_plural: "De volgende fouten zijn opgetreden:"
label_not_prioritized: geen prioriteit
points_committed: points toegezegd
field_remaining_hours: Resterende uren
error_intro_singular: "De volgende fout is opgetreden:"
backlogs_sizing_inconsistent: "Story sizes varieren ten opzichte van hun tijdsschattingen"
label_hours: uren
label_back_to_project: Terug naar het project
points_committed: points toegezegd
label_Product_backlog: Product backlog
label_product_backlog: product backlog
label_back_to_project: Terug naar het project
backlogs_sprint_notes_missing: "Gesloten sprints zonder retrospective/review aantekeningen"
backlogs_story_tracker: Story trackers
backlogs_task: Task
backlogs_any: alle
sprint_notes_missing: De laatste sprint heeft geen notities
project_dormant: Project vertoont geen activiteit
label_points: points
label_sprint_backlog: sprint backlog
label_sprint_velocity: Velocity {{velocity}}, berekend over {{sprints}} sprints met gemiddeld {{days}} dagen
points_accepted: points geaccepteerd
label_backlogs: Backlogs
backlogs_task: Task
subject: Onderwerp
label_stories_tasks: Stories/Taken
field_backlogs_issue_type: Backlog type
label_points_burn_up: Omhoog
backlogs_any: alle
label_burndown: Burndown
points_to_resolve: points niet opgelost
points_resolved: points opgelost
product_backlog_unsized: De top van de product backlog heeft stories zonder sizes
active_sprint_unestimated_tasks: De lopende sprint heeft taken zonder urenschatting
pessimistic_velocity: Project plant {{pct}}% te pessimistisch
backlogs_active: lopend
label_burndown: Burndown
label_points_burn_up: Omhoog
field_backlogs_issue_type: Backlog type
points_to_accept: points niet geaccepteerd
required_burn_rate_points: benodigde burn rate (points)
field_story_points: Story points
field_position: Positie
required_burn_rate_points: benodigde burn rate (points)
points_to_accept: points niet geaccepteerd
points_accepted: points geaccepteerd
label_sprint_name: Sprint "{{name}}"
backlogs_product_backlog: Product backlog
remaining_hours: resterende uren
optimistic_velocity: Project plant {{pct}}% te optimistisch
backlogs_product_backlog: Product backlog
label_blocks: Blokken
backlogs_inactive: "Project lijkt inactief"
todo_issue_description: |-
{{summary}}: {{url}}
{{description}}
label_chart_options: Grafiek Opties
backlogs_task_tracker: Task tracker
backlogs_sprints: Sprints
product_backlog_empty: Product backlog is leeg
active_sprint_unsized_stories: De lopende sprint heeft stories zonder size
no_velocity: Project heeft geen velocity
backlogs_story: Story
ideal: ideaal
todo_issue_summary: "{{type}}: {{summary}}"
backlogs_product_backlog_is_empty: "Product backlog is leeg"
field_velocity_based_estimate: Schatting obv velocity
label_backlogs_unconfigured: U heeft Backlogs nog niet geconfigureerd. Gaat u asltublieft naar {{administration}} > {{plugins}}, en klik dan op de {{configure}} link voor deze plugin. Nadat u de configuratie hebt opgeslagen kunt u teruggaan naar deze pagina om de plugin in gebruik te nemen.
event_sprint_description: |-
{{summary}}: {{url}}
{{description}}
ideal: ideaal
backlogs_story: Story
label_scrum_statistics: Scrum statistieken
label_backlogs_unconfigured: U heeft Backlogs nog niet geconfigureerd. Gaat u asltublieft naar {{administration}} > {{plugins}}, en klik dan op de {{configure}} link voor deze plugin. Nadat u de configuratie hebt opgeslagen kunt u teruggaan naar deze pagina om de plugin in gebruik te nemen.
label_stories: Stories
label_sprint_velocity: "Velocity {{velocity}}, berekend over {{sprints}} sprints met gemiddeld {{days}} dagen"
error_must_have_comma_delimited_list: moet een komma-gescheiden lijst van story of taak IDs hebben
event_sprint_summary: "{{project}}: {{summary}}"
event_sprint_description: "{{summary}}: {{url}}\n{{description}}"
todo_issue_summary: "{{type}}: {{summary}}"
todo_issue_description: "{{summary}}: {{url}}\n{{description}}"

@ -1,10 +1,89 @@
require_dependency 'project'
module Backlogs
class Statistics
def initialize
@errors = {}
@info = {}
end
def []=(cat, key, *args)
raise "Unexpected data category #{cat}" unless [:error, :info].include?(cat)
case args.size
when 2
subkey, value = *args
when 1
value = args[0]
subkey = nil
else
raise "Unexpected number of argments"
end
case cat
when :error
if subkey.nil?
raise "Already reported #{key.inspect}" if @errors.include?(key)
@errors[key] = value.nil? ? nil : (!!value)
else
raise "Already reported #{key.inspect}" if @errors.include?(key) && ! @errors[key].is_a?(Hash)
@errors[key] ||= {}
raise "Already errors #{key.inspect}/#{subkey.inspect}" if @errors[key].include?(subkey)
@errors[key][subkey] = value.nil? ? nil : (!!value)
end
when :info
raise "Already added info #{key.inspect}" if @info.include?(key)
@info[key] = value
end
end
def score
score = {}
@errors.each_pair{ |k, v|
if v.is_a? Hash
score[k] = v.values.select{|elt| !elt.nil? }.inject(true){|all, elt| all && elt}
else
score[k] = v if !v.nil?
end
}
return ((score.values.select{|v| v}.size * 10) / score.size)
end
def scores(prefix='')
score = {}
@errors.each_pair{|k, v|
if v.is_a? Hash
v.each_pair {|sk, rv|
score["#{prefix}#{k}_#{sk}".intern] = rv if !rv.nil?
}
else
score["#{prefix}#{k}".intern] = v if !v.nil?
end
}
return score
end
def errors(prefix = '')
score = scores(prefix)
return score.keys.select{|k| score[k]}
end
def info(prefix='')
info = {}
@info.each_pair {|k, v|
info["#{prefix}#{k}".intern] = v
}
return info
end
end
module ProjectPatch
def self.included(base) # :nodoc:
base.extend(ClassMethods)
base.send(:include, InstanceMethods)
base.extend(ClassMethods)
base.send(:include, InstanceMethods)
end
module ClassMethods
@ -12,137 +91,109 @@ module Backlogs
module InstanceMethods
def active_sprint
return Sprint.find(:first,
:conditions => ["project_id = ? and status = 'open' and ? between sprint_start_date and effective_date", self.id, Time.now])
def active_sprint
return Sprint.find(:first,
:conditions => ["project_id = ? and status = 'open' and ? between sprint_start_date and effective_date", self.id, Time.now])
end
def scrum_statistics
## pretty expensive to compute, so if we're calling this multiple times, return the cached results
return @scrum_statistics if @scrum_statistics
@scrum_statistics = Backlogs::Statistics.new
# magic constant
backlog = Story.product_backlog(self, 10)
active_sprint = self.active_sprint
closed_sprints = Sprint.find(:all,
:conditions => ["project_id = ? and status in ('closed', 'locked') and not(effective_date is null or sprint_start_date is null)", self.id],
:order => "effective_date desc",
:limit => 5)
all_sprints = ([active_sprint] + closed_sprints).compact
@scrum_statistics[:info, :active_sprint] = active_sprint
@scrum_statistics[:info, :closed_sprints] = closed_sprints
@scrum_statistics[:error, :product_backlog, :is_empty] = (self.status == Project::STATUS_ACTIVE && backlog.length == 0)
@scrum_statistics[:error, :product_backlog, :unsized] = backlog.inject(false) {|unsized, story| unsized || story.story_points.nil? }
@scrum_statistics[:error, :sprint, :unsized] = Issue.exists?(["story_points is null and parent_id is null and fixed_version_id in (?) and tracker_id in (?)", all_sprints.collect{|s| s.id}, Story.trackers])
@scrum_statistics[:error, :sprint, :unestimated] = Issue.exists?(["estimated_hours is null and not parent_id is null and fixed_version_id in (?) and tracker_id = ?", all_sprints.collect{|s| s.id}, Task.tracker])
@scrum_statistics[:error, :sprint, :notes_missing] = closed_sprints.inject(false){|missing, sprint| missing || !sprint.has_wiki_page}
@scrum_statistics[:error, :inactive] = (self.status == Project::STATUS_ACTIVE && !(active_sprint && active_sprint.activity))
velocity = nil
begin
points = 0
error = 0
days = 0
closed_sprints.each {|sprint|
bd = sprint.burndown('up')
accepted = (bd.points_accepted || [0])[-1]
committed = (bd.points_committed || [0])[0]
error += (1 - (accepted.to_f / committed.to_f)).abs
points += accepted
days += bd.ideal.size
}
error = (error / closed_sprints.size)
# magic constant
@scrum_statistics[:error, :velocity, :varies] = (error > 0.1)
@scrum_statistics[:error, :velocity, :missing] = false
velocity = (points / closed_sprints.size)
@scrum_statistics[:info, :velocity_divergance] = error * 100
rescue ZeroDivisionError
@scrum_statistics[:error, :velocity, :varies] = nil
@scrum_statistics[:error, :velocity, :missing] = true
@scrum_statistics[:info, :velocity_divergance] = nil
end
@scrum_statistics[:info, :velocity] = velocity
begin
dps = (all_sprints.inject(0){|d, s| d + s.days.size} / all_sprints.size)
@scrum_statistics[:info, :average_days_per_sprint] = dps
@scrum_statistics[:info, :average_days_per_point] = (velocity ? dps.to_f / velocity : nil)
rescue ZeroDivisionError
@scrum_statistics[:info, :average_days_per_sprint] = nil
@scrum_statistics[:info, :average_days_per_point] = nil
end
def scrum_statistics
## pretty expensive to compute, so if we're calling this multiple times, return the cached results
return @scrum_statistics if @scrum_statistics
stats = {}
score = []
backlog = Story.product_backlog(self)[0,10]
if backlog.length == 0
score << l(:product_backlog_empty)
elsif backlog.inject(true) {|unsized, story| unsized && story.story_points.nil? }
score << l(:product_backlog_unsized)
else
score << nil
end
active = self.active_sprint
if active
stats[:active_sprint] = active
score <<
(Issue.exists?(["id <> root_id and estimated_hours is NULL and fixed_version_id =? and tracker_id = ?", active.id, Task.tracker]) ?
l(:active_sprint_unsized_stories) : nil)
score << (
Issue.exists?(["id <> root_id and estimated_hours is NULL and fixed_version_id = ? and tracker_id = ?", active.id, Task.tracker]) ?
l(:active_sprint_unestimated_tasks) : nil)
score << (!active.activity ? l(:active_sprint_dormant) : nil)
end
## base sprint stats on the last 5 closed sprints
sprints = Sprint.find(:all,
:conditions => ["project_id = ? and status in ('closed', 'locked') and not(effective_date is null or sprint_start_date is null)", self.id],
:order => "effective_date desc",
:limit => 5)
planned_velocity = nil
if sprints.length == 0
stats[:sprints] = []
else
stats[:sprints] = sprints
sprint_ids = sprints.collect{|s| "#{s.id}"}.join(',')
story_trackers = Story.trackers.collect{|s| "#{s.object_id}"}.join(',')
score << (
Issue.exists?(["id = root_id and story_points is NULL and fixed_version_id in (#{sprint_ids}) and tracker_id in (?)", Story.trackers]) ?
l(:unsized_stories, {:sprints => sprints.length}) : nil)
score << (
Issue.exists?(["id <> root_id and estimated_hours is NULL and fixed_version_id in (#{sprint_ids}) and tracker_id = ?", Task.tracker]) ?
l(:unestimated_tasks, {:sprints => sprints.length}) : nil)
## average points per hour over the selected sprints
points_per_hour = nil
res = Project.connection.execute("
select avg(story_points), avg(estimated_hours)
from issues
where not story_points is null
and fixed_version_id in (#{sprint_ids})
and id = root_id
and tracker_id in (#{story_trackers})
")
res.each {|p, h|
points_per_hour = ((1.0 * p) / h) if h && h != 0
}
accepted = 0
committed = 0
days = 0
pph_count = 0
pph_diff = 0
sprints.each {|sprint|
days += sprint.days.length
bd = sprint.burndown('up')
if bd
_accepted = bd.points_accepted || [0]
_committed = bd.points_committed || [0]
_remaining = bd.remaining_hours || [0]
accepted += _accepted[-1]
committed += _committed[0]
if points_per_hour && _remaining[0] > 0
pph = (1.0 * _committed[0]) / _remaining[0]
pph_count += 1
pph_diff += (pph - points_per_hour).abs
end
end
}
if points_per_hour and pph_count > 0
pph_variance = (Integer(100 * (pph_diff / pph_count)) - 100).abs
score << (pph_variance > 10 ? l(:size_accuracy, {:pct => pph_variance}) : nil)
end
last_sprint = sprints[0]
score << (last_sprint.effective_date < -14.days.from_now.to_date ? l(:project_dormant) : nil) if !active
score << (!last_sprint.has_wiki_page ? l(:sprint_notes_missing) : nil)
stats[:average_days_per_sprint] = days / sprints.length
stats[:velocity] = accepted / sprints.length
planned_velocity = committed / sprints.length
stats[:days_per_point] = (stats[:average_days_per_sprint] * 1.0) / stats[:velocity] if stats[:velocity] > 0
end
stats[:velocity] ||= 0
score << (stats[:velocity] == 0 ? l(:no_velocity) : nil)
if stats[:velocity] > 0
planned_velocity ||= 0
mood = Integer((100.0 * planned_velocity) / stats[:velocity]) - 100
if mood > 10
score << l(:optimistic_velocity, {:pct => mood})
elsif mood < -10
score << l(:pessimistic_velocity, {:pct => mood})
else
score << nil
end
end
stats[:score] = {
:score => (10 * (score.size - score.compact.size)) / score.size,
:errors => score.compact
sizing_divergance = nil
sizing_is_consistent = false
sprint_ids = all_sprints.collect{|s| "#{s.id}"}.join(',')
story_trackers = Story.trackers.collect{|t| "#{t}"}.join(',')
if sprint_ids != '' && story_trackers != ''
select_stories = "
not (story_points is null or story_points = 0)
and not (estimated_hours is null or estimated_hours = 0)
and fixed_version_id in (#{sprint_ids})
and project_id = #{self.id}
and not parent_id is null
and tracker_id in (#{story_trackers})
"
points_per_hour = Story.find_by_sql("select avg(story_points) / avg(estimated_hours) as points_per_hour from issues where #{select_stories}")[0].points_per_hour
if points_per_hour
stories = Stories.select(:all, :conditions => [select_stories])
error = stories.inject(0) {|err, story|
err + (1 - (points_per_hour / (story.story_points / story.estimated_hours)))
}
@scrum_statistics = stats
return @scrum_statistics
sizing_divergance = error * 100
# magic constant
sizing_is_consistent = (error < 0.1)
end
end
@scrum_statistics[:info, :sizing_divergance] = sizing_divergance
@scrum_statistics[:error, :sizing_inconsistent] = !sizing_is_consistent
return @scrum_statistics
end
end
end

@ -18,6 +18,9 @@ namespace :redmine do
next if lang[l][key]
lang[l][key] = "[[#{txt}]]"
}
lang[l].keys.each {|k|
lang[l].delete(k) unless template[k]
}
File.open( lang_file, 'w' ) do |out|
YAML.dump(lang, out)

Loading…
Cancel
Save