Class: SprintBurndown

Inherits:
ChartBase show all
Defined in:
lib/jirametrics/sprint_burndown.rb

Constant Summary

Constants inherited from ChartBase

ChartBase::LABEL_POSITIONS, ChartBase::OKABE_ITO_PALETTE

Instance Attribute Summary collapse

Attributes inherited from ChartBase

#aggregated_project, #all_boards, #atlassian_document_format, #canvas_height, #canvas_width, #data_quality, #date_range, #file_system, #fix_versions, #holiday_dates, #issues, #settings, #time_range, #timezone_offset, #x_axis_title, #y_axis_title

Instance Method Summary collapse

Methods inherited from ChartBase

#aggregated_project?, #before_run, #call_before_run, #canvas, #canvas_responsive?, #chart_format, #collapsible_issues_panel, #color_block, #color_for, #completed_issues_in_range, #current_board, #cycletime, #cycletime_for_issue, #daily_chart_dataset, #date_annotation, #describe_non_working_days, #description_text, #format_integer, #format_status, #header_text, #holidays, #html_directory, #icon_span, #link_to_issue, #next_id, #non_working_day?, #normalize_annotation_datetime, #not_visible_icon, #not_visible_text, #random_color, #render, #render_axis_title, #render_top_text, #resolve_status, #stagger_label_positions, #status_category_color, #to_human_readable, #working_days_annotation, #wrap_and_render

Constructor Details

#initializeSprintBurndown

Returns a new instance of SprintBurndown.



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# File 'lib/jirametrics/sprint_burndown.rb', line 7

def initialize
  super

  @summary_stats = {}
  header_text 'Sprint burndown'
  description_text <<-TEXT
    <div class="p">
      Burndowns for all sprints in this time period. The different colours are only to
      differentiate one sprint from another as they may overlap time periods.
    </div>
    #{describe_non_working_days}
  TEXT
  @x_axis_title = 'Date'
  @y_axis_title = 'Items remaining'
end

Instance Attribute Details

#board_idObject

Returns the value of attribute board_id.



5
6
7
# File 'lib/jirametrics/sprint_burndown.rb', line 5

def board_id
  @board_id
end

#summary_statsObject (readonly)

Returns the value of attribute summary_stats.



4
5
6
# File 'lib/jirametrics/sprint_burndown.rb', line 4

def summary_stats
  @summary_stats
end

#use_story_countsObject (readonly)

Returns the value of attribute use_story_counts.



4
5
6
# File 'lib/jirametrics/sprint_burndown.rb', line 4

def use_story_counts
  @use_story_counts
end

#use_story_pointsObject (readonly)

Returns the value of attribute use_story_points.



4
5
6
# File 'lib/jirametrics/sprint_burndown.rb', line 4

def use_story_points
  @use_story_points
end

Instance Method Details

#append_closing_points(data_set, measure, sprint, start_written) ⇒ Object



249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/jirametrics/sprint_burndown.rb', line 249

def append_closing_points data_set, measure, sprint, start_written
  # Nothing in the change stream triggered the sprint-start marker, so write it now.
  data_set << sprint_start_point(measure, sprint) unless start_written

  if sprint.completed_time
    data_set << { y: measure.value, x: chart_format(sprint.completed_time), title: measure.ended_title }
    measure.summary_stats.remaining = measure.value
  end
  return if sprint.completed_at?(time_range.end)

  data_set << { y: measure.value, x: chart_format(time_range.end), title: measure.active_title }
end

#build_sprint_data_set(sprint:, change_data_for_sprint:, measure:) ⇒ Object

Walks the sprint's change stream once. The measure decides how each change accumulates and reads out; the loop structure is shared by both the story-points and story-counts views.



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/jirametrics/sprint_burndown.rb', line 209

def build_sprint_data_set sprint:, change_data_for_sprint:, measure:
  data_set = []
  start_written = false

  change_data_for_sprint.each do |change_data|
    if start_pending?(start_written, change_data, sprint)
      data_set << sprint_start_point(measure, sprint)
      start_written = true
    end
    break if past_sprint_end?(change_data, sprint)

    measure.update_state change_data
    next unless change_data.time >= sprint.start_time

    message = measure.record change_data
    data_set << change_point(measure, change_data, message) if message
  end

  append_closing_points data_set, measure, sprint, start_written
  @summary_stats[sprint] = measure.summary_stats
  data_set
end

#change_point(measure, change_data, message) ⇒ Object



245
246
247
# File 'lib/jirametrics/sprint_burndown.rb', line 245

def change_point measure, change_data, message
  { y: measure.value, x: chart_format(change_data.time), title: "#{change_data.issue.key} #{message}" }
end

#changes_for_one_issue(issue:, sprint:) ⇒ Object

select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/jirametrics/sprint_burndown.rb', line 133

def changes_for_one_issue issue:, sprint:
  estimate = 0.0
  currently_in_sprint = false
  completed_has_been_tracked = false
  change_data = []

  estimate_display_name = current_board.estimation_configuration.display_name
  issue_completed_time = issue.started_stopped_times.last

  issue.changes.each do |change|
    action = nil
    value = nil

    if change.sprint?
      in_change_item = sprint_in_change_item(sprint, change)
      action, value = sprint_change_action(in_change_item, currently_in_sprint, estimate)
      currently_in_sprint = in_change_item
    elsif estimate_change_before_completion?(change, estimate_display_name, issue_completed_time)
      action = :story_points
      estimate = change.value.to_f
      value = estimate - change.old_value.to_f
    elsif reached_completion?(completed_has_been_tracked, change, issue_completed_time)
      completed_has_been_tracked = true
      action = :issue_stopped
      value = -estimate
    end

    next unless action

    change_data << SprintIssueChangeData.new(
      time: change.time, issue: issue, action: action, value: value, estimate: estimate
    )
  end

  ever_entered_sprint?(change_data) ? change_data : []
end

#charts_to_generateObject

The [data-builder method, y-axis label] pairs for whichever burndown measures are turned on.



71
72
73
74
75
76
# File 'lib/jirametrics/sprint_burndown.rb', line 71

def charts_to_generate
  charts = []
  charts << [:data_set_by_story_points, 'Story Points'] if @use_story_points
  charts << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
  charts
end

#data_set_by_story_counts(sprint:, change_data_for_sprint:) ⇒ Object



201
202
203
204
205
# File 'lib/jirametrics/sprint_burndown.rb', line 201

def data_set_by_story_counts sprint:, change_data_for_sprint:
  build_sprint_data_set(
    sprint: sprint, change_data_for_sprint: change_data_for_sprint, measure: SprintCountMeasure.new
  )
end

#data_set_by_story_points(sprint:, change_data_for_sprint:) ⇒ Object



195
196
197
198
199
# File 'lib/jirametrics/sprint_burndown.rb', line 195

def data_set_by_story_points sprint:, change_data_for_sprint:
  build_sprint_data_set(
    sprint: sprint, change_data_for_sprint: change_data_for_sprint, measure: SprintPointsMeasure.new
  )
end

#estimate_change_before_completion?(change, estimate_display_name, issue_completed_time) ⇒ Boolean

Returns:

  • (Boolean)


179
180
181
# File 'lib/jirametrics/sprint_burndown.rb', line 179

def estimate_change_before_completion? change, estimate_display_name, issue_completed_time
  change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
end

#ever_entered_sprint?(change_data) ⇒ Boolean

Returns:

  • (Boolean)


187
188
189
# File 'lib/jirametrics/sprint_burndown.rb', line 187

def ever_entered_sprint? change_data
  change_data.any? { |data| data.action == :enter_sprint }
end

#gather_change_data_by_sprint(sprints) ⇒ Object

Every sprint's changes, flattened across all issues and sorted into time order.



63
64
65
66
67
68
# File 'lib/jirametrics/sprint_burndown.rb', line 63

def gather_change_data_by_sprint sprints
  sprints.to_h do |sprint|
    change_data = issues.flat_map { |issue| changes_for_one_issue(issue: issue, sprint: sprint) }
    [sprint, change_data.sort_by(&:time)]
  end
end

#legend_for(data_method) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/jirametrics/sprint_burndown.rb', line 98

def legend_for data_method
  case data_method
  when :data_set_by_story_counts
    ['<b>Started</b>: Number of issues already in the sprint, when the sprint was started.',
     '<b>Completed</b>: Number of issues, completed during the sprint',
     '<b>Added</b>: Number of issues added in the middle of the sprint',
     '<b>Removed</b>: Number of issues removed while the sprint was in progress']
  when :data_set_by_story_points
    ['<b>Started</b>: Total count of story points when the sprint was started',
     '<b>Completed</b>: Count of story points completed during the sprint',
     '<b>Added</b>: Count of story points added in the middle of the sprint',
     '<b>Removed</b>: Count of story points removed while the sprint was in progress']
  else
    raise "Unexpected method #{data_method}"
  end
end

#options=(arg) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/jirametrics/sprint_burndown.rb', line 23

def options= arg
  case arg
  when :points_only
    @use_story_points = true
    @use_story_counts = false
  when :counts_only
    @use_story_points = false
    @use_story_counts = true
  when :points_and_counts
    @use_story_points = true
    @use_story_counts = true
  else
    raise "Unexpected option: #{arg}"
  end
end

#past_sprint_end?(change_data, sprint) ⇒ Boolean

Returns:

  • (Boolean)


236
237
238
# File 'lib/jirametrics/sprint_burndown.rb', line 236

def past_sprint_end? change_data, sprint
  sprint.completed_time && change_data.time > sprint.completed_time
end

#reached_completion?(completed_has_been_tracked, change, issue_completed_time) ⇒ Boolean

Returns:

  • (Boolean)


183
184
185
# File 'lib/jirametrics/sprint_burndown.rb', line 183

def reached_completion? completed_has_been_tracked, change, issue_completed_time
  completed_has_been_tracked == false && change.time == issue_completed_time
end

#runObject



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/jirametrics/sprint_burndown.rb', line 39

def run
  return nil unless current_board.scrum?

  sprints = sprints_in_time_range current_board
  change_data_by_sprint = gather_change_data_by_sprint sprints

  result = +''
  result << render_top_text(binding)

  # HashEachMethods misreads this array of [method, title] pairs as a hash and thinks y_axis_title
  # is unused; in fact the ERB template reads it (and data_method) from the binding we pass to render.
  charts_to_generate.each do |data_method, y_axis_title| # rubocop:disable Style/HashEachMethods
    @summary_stats.clear
    data_sets = sprint_data_sets(
      data_method: data_method, sprints: sprints, change_data_by_sprint: change_data_by_sprint
    )
    legend = legend_for data_method
    result << render(binding, __FILE__)
  end

  result
end

#sprint_change_action(in_change_item, currently_in_sprint, estimate) ⇒ Object

Two sprint changes in a row can say the same thing, so an event only fires when the membership actually flips: entering (was out, now in) or leaving (was in, now out).



172
173
174
175
176
177
# File 'lib/jirametrics/sprint_burndown.rb', line 172

def sprint_change_action in_change_item, currently_in_sprint, estimate
  return [:enter_sprint, estimate] if currently_in_sprint == false && in_change_item
  return [:leave_sprint, -estimate] if currently_in_sprint && in_change_item == false

  [nil, nil]
end

#sprint_data_sets(data_method:, sprints:, change_data_by_sprint:) ⇒ Object

One Chart.js data set per sprint, each in its own colour from the palette.



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/jirametrics/sprint_burndown.rb', line 79

def sprint_data_sets data_method:, sprints:, change_data_by_sprint:
  possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect do |i|
    CssVariable["--sprint-burndown-sprint-color-#{i}"]
  end
  sprints.each_with_index.collect do |sprint, index|
    color = possible_colours[index % possible_colours.size]
    {
      label: sprint.name,
      data: send(data_method, sprint: sprint, change_data_for_sprint: change_data_by_sprint[sprint]),
      fill: false,
      showLine: true,
      borderColor: color,
      backgroundColor: color,
      stepped: true,
      pointStyle: %w[rect circle] # First dot is visually different from the rest
    }
  end
end

#sprint_in_change_item(sprint, change_item) ⇒ Object



191
192
193
# File 'lib/jirametrics/sprint_burndown.rb', line 191

def sprint_in_change_item sprint, change_item
  change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
end

#sprint_in_time_range?(sprint) ⇒ Boolean

A sprint counts when it has actually started (future and never-started sprints are just holding areas, so we ignore them) and its active span overlaps the chart's time range.

Returns:

  • (Boolean)


121
122
123
124
125
126
127
128
129
130
# File 'lib/jirametrics/sprint_burndown.rb', line 121

def sprint_in_time_range? sprint
  return false if sprint.future?

  sprint_start_time = sprint.start_time
  return false if sprint_start_time.nil?

  sprint_end_time = sprint.completed_time || sprint.end_time
  time_range.include?(sprint_start_time) || time_range.include?(sprint_end_time) ||
    (sprint_start_time < time_range.begin && sprint_end_time > time_range.end)
end

#sprint_start_point(measure, sprint) ⇒ Object



240
241
242
243
# File 'lib/jirametrics/sprint_burndown.rb', line 240

def sprint_start_point measure, sprint
  measure.summary_stats.started = measure.value
  { y: measure.value, x: chart_format(sprint.start_time), title: measure.started_title }
end

#sprints_in_time_range(board) ⇒ Object



115
116
117
# File 'lib/jirametrics/sprint_burndown.rb', line 115

def sprints_in_time_range board
  board.sprints.select { |sprint| sprint_in_time_range? sprint }
end

#start_pending?(start_written, change_data, sprint) ⇒ Boolean

Returns:

  • (Boolean)


232
233
234
# File 'lib/jirametrics/sprint_burndown.rb', line 232

def start_pending? start_written, change_data, sprint
  !start_written && change_data.time >= sprint.start_time
end