jirametrics 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/bin/jirametrics +4 -0
  3. data/lib/jirametrics/aggregate_config.rb +89 -0
  4. data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
  6. data/lib/jirametrics/aging_work_table.rb +149 -0
  7. data/lib/jirametrics/anonymizer.rb +186 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +43 -0
  9. data/lib/jirametrics/board.rb +85 -0
  10. data/lib/jirametrics/board_column.rb +14 -0
  11. data/lib/jirametrics/board_config.rb +31 -0
  12. data/lib/jirametrics/change_item.rb +80 -0
  13. data/lib/jirametrics/chart_base.rb +239 -0
  14. data/lib/jirametrics/columns_config.rb +42 -0
  15. data/lib/jirametrics/cycletime_config.rb +69 -0
  16. data/lib/jirametrics/cycletime_histogram.rb +74 -0
  17. data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
  20. data/lib/jirametrics/daily_wip_chart.rb +123 -0
  21. data/lib/jirametrics/data_quality_report.rb +278 -0
  22. data/lib/jirametrics/dependency_chart.rb +217 -0
  23. data/lib/jirametrics/discard_changes_before.rb +37 -0
  24. data/lib/jirametrics/download_config.rb +41 -0
  25. data/lib/jirametrics/downloader.rb +337 -0
  26. data/lib/jirametrics/examples/aggregated_project.rb +36 -0
  27. data/lib/jirametrics/examples/standard_project.rb +111 -0
  28. data/lib/jirametrics/expedited_chart.rb +169 -0
  29. data/lib/jirametrics/experimental/generator.rb +209 -0
  30. data/lib/jirametrics/experimental/info.rb +77 -0
  31. data/lib/jirametrics/exporter.rb +127 -0
  32. data/lib/jirametrics/file_config.rb +119 -0
  33. data/lib/jirametrics/fix_version.rb +21 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +44 -0
  35. data/lib/jirametrics/grouping_rules.rb +13 -0
  36. data/lib/jirametrics/hierarchy_table.rb +31 -0
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
  39. data/lib/jirametrics/html/aging_work_table.erb +60 -0
  40. data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
  41. data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
  42. data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
  44. data/lib/jirametrics/html/data_quality_report.erb +126 -0
  45. data/lib/jirametrics/html/expedited_chart.erb +67 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +29 -0
  47. data/lib/jirametrics/html/index.erb +66 -0
  48. data/lib/jirametrics/html/sprint_burndown.erb +116 -0
  49. data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
  50. data/lib/jirametrics/html/throughput_chart.erb +65 -0
  51. data/lib/jirametrics/html_report_config.rb +217 -0
  52. data/lib/jirametrics/issue.rb +521 -0
  53. data/lib/jirametrics/issue_link.rb +60 -0
  54. data/lib/jirametrics/json_file_loader.rb +9 -0
  55. data/lib/jirametrics/project_config.rb +442 -0
  56. data/lib/jirametrics/rules.rb +34 -0
  57. data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
  58. data/lib/jirametrics/sprint.rb +43 -0
  59. data/lib/jirametrics/sprint_burndown.rb +335 -0
  60. data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
  61. data/lib/jirametrics/status.rb +26 -0
  62. data/lib/jirametrics/status_collection.rb +67 -0
  63. data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
  64. data/lib/jirametrics/throughput_chart.rb +91 -0
  65. data/lib/jirametrics/tree_organizer.rb +96 -0
  66. data/lib/jirametrics/trend_line_calculator.rb +74 -0
  67. data/lib/jirametrics.rb +85 -0
  68. metadata +167 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/self_or_issue_dispatcher'
4
+
5
+ class ColumnsConfig
6
+ include SelfOrIssueDispatcher
7
+
8
+ attr_reader :columns, :file_config
9
+
10
+ def initialize file_config:, block:
11
+ @columns = []
12
+ @file_config = file_config
13
+ @block = block
14
+ end
15
+
16
+ def run
17
+ instance_eval(&@block)
18
+ end
19
+
20
+ def write_headers headers = nil
21
+ @write_headers = headers unless headers.nil?
22
+ @write_headers
23
+ end
24
+
25
+ def date label, proc
26
+ @columns << [:date, label, proc]
27
+ end
28
+
29
+ def datetime label, proc
30
+ @columns << [:datetime, label, proc]
31
+ end
32
+
33
+ def string label, proc
34
+ @columns << [:string, label, proc]
35
+ end
36
+
37
+ def column_entry_times board_id: nil
38
+ @file_config.project_config.find_board_by_id(board_id).visible_columns.each do |column|
39
+ date column.name, first_time_in_status(*column.status_ids)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/self_or_issue_dispatcher'
4
+ require 'date'
5
+
6
+ class CycleTimeConfig
7
+ include SelfOrIssueDispatcher
8
+
9
+ attr_reader :label, :parent_config
10
+
11
+ def initialize parent_config:, label:, block:, today: Date.today
12
+ @parent_config = parent_config
13
+ @label = label
14
+ @today = today
15
+ instance_eval(&block) unless block.nil?
16
+ end
17
+
18
+ def start_at block = nil
19
+ @start_at = block unless block.nil?
20
+ @start_at
21
+ end
22
+
23
+ def stop_at block = nil
24
+ @stop_at = block unless block.nil?
25
+ @stop_at
26
+ end
27
+
28
+ def in_progress? issue
29
+ started_time(issue) && stopped_time(issue).nil?
30
+ end
31
+
32
+ def done? issue
33
+ stopped_time(issue)
34
+ end
35
+
36
+ def started_time issue
37
+ @start_at.call(issue)
38
+ end
39
+
40
+ def stopped_time issue
41
+ @stop_at.call(issue)
42
+ end
43
+
44
+ def cycletime issue
45
+ start = started_time(issue)
46
+ stop = stopped_time(issue)
47
+ return nil if start.nil? || stop.nil?
48
+
49
+ (stop.to_date - start.to_date).to_i + 1
50
+ end
51
+
52
+ def age issue, today: nil
53
+ start = started_time(issue)
54
+ stop = today || @today || Date.today
55
+ return nil if start.nil? || stop.nil?
56
+
57
+ (stop.to_date - start.to_date).to_i + 1
58
+ end
59
+
60
+ def possible_statuses
61
+ if parent_config.is_a? BoardConfig
62
+ project_config = parent_config.project_config
63
+ else
64
+ # TODO: This will go away when cycletimes are no longer supported inside html_reports
65
+ project_config = parent_config.file_config.project_config
66
+ end
67
+ project_config.possible_statuses
68
+ end
69
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class CycletimeHistogram < ChartBase
6
+ include GroupableIssueChart
7
+ attr_accessor :possible_statuses
8
+
9
+ def initialize block = nil
10
+ super()
11
+
12
+ header_text 'Cycletime Histogram'
13
+ description_text <<-HTML
14
+ <p>
15
+ The Cycletime Histogram shows how many items completed in a certain timeframe. This can be
16
+ useful for determining how many different types of work are flowing through, based on the
17
+ lengths of time they take.
18
+ </p>
19
+ HTML
20
+
21
+ init_configuration_block(block) do
22
+ grouping_rules do |issue, rule|
23
+ rule.label = issue.type
24
+ rule.color = color_for type: issue.type
25
+ end
26
+ end
27
+ end
28
+
29
+ def run
30
+ stopped_issues = completed_issues_in_range include_unstarted: true
31
+
32
+ # For the histogram, we only want to consider items that have both a start and a stop time.
33
+ histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_time(issue) }
34
+ rules_to_issues = group_issues histogram_issues
35
+
36
+ data_sets = rules_to_issues.keys.collect do |rules|
37
+ data_set_for(
38
+ histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
39
+ label: rules.label,
40
+ color: rules.color
41
+ )
42
+ end
43
+
44
+ wrap_and_render(binding, __FILE__)
45
+ end
46
+
47
+ def histogram_data_for issues:
48
+ count_hash = {}
49
+ issues.each do |issue|
50
+ days = issue.board.cycletime.cycletime(issue)
51
+ count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
52
+ end
53
+ count_hash
54
+ end
55
+
56
+ def data_set_for histogram_data:, label:, color:
57
+ keys = histogram_data.keys.sort
58
+ {
59
+ type: 'bar',
60
+ label: label,
61
+ data: keys.sort.collect do |key|
62
+ next if histogram_data[key].zero?
63
+
64
+ {
65
+ x: key,
66
+ y: histogram_data[key],
67
+ title: "#{histogram_data[key]} items completed in #{label_days key}"
68
+ }
69
+ end.compact,
70
+ backgroundColor: color,
71
+ borderRadius: 0
72
+ }
73
+ end
74
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class CycletimeScatterplot < ChartBase
6
+ include GroupableIssueChart
7
+
8
+ attr_accessor :possible_statuses
9
+
10
+ def initialize block = nil
11
+ super()
12
+
13
+ header_text 'Cycletime Scatterplot'
14
+ description_text <<-HTML
15
+ <p>
16
+ This chart shows only completed work and indicates both what day it completed as well as
17
+ how many days it took to get done. Hovering over a dot will show you the ID of the work item.
18
+ </p>
19
+ <p>
20
+ The gray line indicates the 85th percentile (<%= overall_percent_line %> days). 85% of all
21
+ items on this chart fall on or below the line and the remaining 15% are above the line. 85%
22
+ is a reasonable proxy for "most" so that we can say that based on this data set, we can
23
+ predict that most work of this type will complete in <%= overall_percent_line %> days or
24
+ less. The other lines reflect the 85% line for that respective type of work.
25
+ </p>
26
+ <p>
27
+ The gray vertical bars indicate weekends, when theoretically we aren't working.
28
+ </p>
29
+ HTML
30
+
31
+ init_configuration_block block do
32
+ grouping_rules do |issue, rule|
33
+ rule.label = issue.type
34
+ rule.color = color_for type: issue.type
35
+ end
36
+ end
37
+
38
+ @percentage_lines = []
39
+ @highest_cycletime = 0
40
+ end
41
+
42
+ def run
43
+ completed_issues = completed_issues_in_range include_unstarted: false
44
+
45
+ data_sets = create_datasets completed_issues
46
+ overall_percent_line = calculate_percent_line(completed_issues)
47
+ @percentage_lines << [overall_percent_line, 'gray']
48
+
49
+ return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
50
+
51
+ wrap_and_render(binding, __FILE__)
52
+ end
53
+
54
+ def create_datasets completed_issues
55
+ data_sets = []
56
+
57
+ groups = group_issues completed_issues
58
+
59
+ groups.each_key do |rules|
60
+ completed_issues_by_type = groups[rules]
61
+ label = rules.label
62
+ color = rules.color
63
+ percent_line = calculate_percent_line completed_issues_by_type
64
+ data = completed_issues_by_type.collect { |issue| data_for_issue(issue) }.compact
65
+ data_sets << {
66
+ label: "#{label} (85% at #{label_days(percent_line)})",
67
+ data: data,
68
+ fill: false,
69
+ showLine: false,
70
+ backgroundColor: color
71
+ }
72
+
73
+ data_sets << trend_line_data_set(label: label, data: data, color: color)
74
+
75
+ @percentage_lines << [percent_line, color]
76
+ end
77
+ data_sets
78
+ end
79
+
80
+ def show_trend_lines
81
+ @show_trend_lines = true
82
+ end
83
+
84
+ def trend_line_data_set label:, data:, color:
85
+ points = data.collect do |hash|
86
+ [Time.parse(hash[:x]).to_i, hash[:y]]
87
+ end
88
+
89
+ # The trend calculation works with numbers only so convert Time to an int and back
90
+ calculator = TrendLineCalculator.new(points)
91
+ data_points = calculator.chart_datapoints range: time_range.begin.to_i..time_range.end.to_i, max_y: @highest_cycletime
92
+ data_points.each do |point_hash|
93
+ point_hash[:x] = chart_format Time.at(point_hash[:x])
94
+ end
95
+
96
+ {
97
+ type: 'line',
98
+ label: "#{label} Trendline",
99
+ data: data_points,
100
+ fill: false,
101
+ borderWidth: 1,
102
+ markerType: 'none',
103
+ borderColor: color,
104
+ borderDash: [6, 3],
105
+ pointStyle: 'dash',
106
+ hidden: !@show_trend_lines
107
+ }
108
+ end
109
+
110
+ def data_for_issue issue
111
+ cycle_time = issue.board.cycletime.cycletime(issue)
112
+ return nil if cycle_time < 1 # These will get called out on the quality report
113
+
114
+ @highest_cycletime = cycle_time if @highest_cycletime < cycle_time
115
+
116
+ {
117
+ y: cycle_time,
118
+ x: chart_format(issue.board.cycletime.stopped_time(issue)),
119
+ title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
120
+ }
121
+ end
122
+
123
+ def calculate_percent_line completed_issues
124
+ times = completed_issues.collect { |issue| issue.board.cycletime.cycletime(issue) }
125
+ index = times.size * 85 / 100
126
+ times.sort[index]
127
+ end
128
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/daily_wip_chart'
4
+
5
+ class DailyWipByAgeChart < DailyWipChart
6
+ def default_header_text
7
+ 'Daily WIP grouped by Age'
8
+ end
9
+
10
+ def default_description_text
11
+ <<-HTML
12
+ <p>
13
+ This chart shows the highest WIP on each given day. The WIP is color coded so you can see
14
+ how old it is and hovering over the bar will show you exactly which work items it relates
15
+ to. The green bar underneath, shows how many items completed on that day.
16
+ </p>
17
+ <p>
18
+ "Completed without being started" reflects the fact that while we know that it completed
19
+ that day, we were unable to determine when it had started. These items will show up in
20
+ white at the top. Note that the white is approximate because we don't know exactly when
21
+ it started so we're guessing.
22
+ </p>
23
+ HTML
24
+ end
25
+
26
+ def default_grouping_rules issue:, rules:
27
+ cycletime = issue.board.cycletime
28
+ started = cycletime.started_time(issue)&.to_date
29
+ stopped = cycletime.stopped_time(issue)&.to_date
30
+
31
+ rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
32
+
33
+ if stopped && started.nil? # We can't tell when it started
34
+ not_started stopped: stopped, rules: rules, created: issue.created.to_date
35
+ elsif stopped == rules.current_date
36
+ stopped_today rules: rules
37
+ else
38
+ group_by_age started: started, rules: rules
39
+ end
40
+ end
41
+
42
+ def not_started stopped:, rules:, created:
43
+ if stopped == rules.current_date
44
+ rules.label = 'Completed but not started'
45
+ rules.color = '#66FF66'
46
+ rules.group_priority = -1
47
+ else
48
+ rules.label = 'Start date unknown'
49
+ rules.color = 'white'
50
+ rules.group_priority = 11
51
+ created_days = rules.current_date - created + 1
52
+ rules.issue_hint = "(created: #{label_days created_days.to_i} earlier, stopped on #{stopped})"
53
+ end
54
+ end
55
+
56
+ def stopped_today rules:
57
+ rules.label = 'Completed'
58
+ rules.color = '#009900'
59
+ rules.group_priority = -2
60
+ end
61
+
62
+ def group_by_age started:, rules:
63
+ age = rules.current_date - started + 1
64
+
65
+ case age
66
+ when 1
67
+ rules.label = 'Less than a day'
68
+ rules.color = '#aaaaaa'
69
+ rules.group_priority = 10 # Highest is top
70
+ when 2..7
71
+ rules.label = 'A week or less'
72
+ rules.color = '#80bfff'
73
+ rules.group_priority = 9
74
+ when 8..14
75
+ rules.label = 'Two weeks or less'
76
+ rules.color = '#ffd700'
77
+ rules.group_priority = 8
78
+ when 15..28
79
+ rules.label = 'Four weeks or less'
80
+ rules.color = '#ce6300'
81
+ rules.group_priority = 7
82
+ else
83
+ rules.label = 'More than four weeks'
84
+ rules.color = '#990000'
85
+ rules.group_priority = 6
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/daily_wip_chart'
4
+
5
+ class DailyWipByBlockedStalledChart < DailyWipChart
6
+ def default_header_text
7
+ 'Daily WIP, grouped by Blocked and Stalled statuses'
8
+ end
9
+
10
+ def default_description_text
11
+ <<-HTML
12
+ <p>
13
+ This chart highlights work that is blocked or stalled on each given day. In Jira terms, blocked
14
+ means that the issue has been "flagged". Stalled indicates that the item hasn't had any updates in 5 days.
15
+ </p>
16
+ <p>
17
+ Note that if an item tracks as both blocked and stalled, it will only show up in the flagged totals.
18
+ It will not be double counted.
19
+ </p>
20
+ <p>
21
+ The white section reflects items that have stopped but for which we can't identify the start date. As
22
+ a result, we are unable to properly show the WIP for these items.
23
+ </p>
24
+ HTML
25
+ end
26
+
27
+ def key_blocked_stalled_change issue:, date:, end_time:
28
+ stalled_change = nil
29
+ blocked_change = nil
30
+
31
+ issue.blocked_stalled_changes_on_date(date: date, end_time: end_time) do |change|
32
+ blocked_change = change if change.blocked?
33
+ stalled_change = change if change.stalled?
34
+ end
35
+
36
+ return blocked_change if blocked_change
37
+ return stalled_change if stalled_change
38
+
39
+ nil
40
+ end
41
+
42
+ def default_grouping_rules issue:, rules:
43
+ started = issue.board.cycletime.started_time(issue)
44
+ stopped_date = issue.board.cycletime.stopped_time(issue)&.to_date
45
+ change = key_blocked_stalled_change issue: issue, date: rules.current_date, end_time: time_range.end
46
+
47
+ stopped_today = stopped_date == rules.current_date
48
+
49
+ if stopped_today && started.nil?
50
+ rules.label = 'Completed but not started'
51
+ rules.color = '#66FF66'
52
+ rules.group_priority = -1
53
+ elsif stopped_today
54
+ rules.label = 'Completed'
55
+ rules.color = '#009900'
56
+ rules.group_priority = -2
57
+ elsif started.nil?
58
+ rules.label = 'Start date unknown'
59
+ rules.color = 'white'
60
+ rules.group_priority = 4
61
+ elsif change&.blocked?
62
+ rules.label = 'Blocked'
63
+ rules.color = 'red'
64
+ rules.group_priority = 1
65
+ rules.issue_hint = "(#{change.reasons})"
66
+ elsif change&.stalled?
67
+ rules.label = 'Stalled'
68
+ rules.color = 'orange'
69
+ rules.group_priority = 2
70
+ rules.issue_hint = "(#{change.reasons})"
71
+ else
72
+ rules.label = 'Active'
73
+ rules.color = 'lightgray'
74
+ rules.group_priority = 3
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+
5
+ class DailyGroupingRules < GroupingRules
6
+ attr_accessor :current_date, :group_priority, :issue_hint
7
+
8
+ def initialize
9
+ super()
10
+ @group_priority = 0
11
+ end
12
+ end
13
+
14
+ class DailyWipChart < ChartBase
15
+ attr_accessor :possible_statuses
16
+
17
+ def initialize block = nil
18
+ super()
19
+
20
+ header_text default_header_text
21
+ description_text default_description_text
22
+
23
+ if block
24
+ instance_eval(&block)
25
+ else
26
+ grouping_rules do |issue, rules|
27
+ default_grouping_rules issue: issue, rules: rules
28
+ end
29
+ end
30
+ end
31
+
32
+ def run
33
+ issue_rules_by_active_date = group_issues_by_active_dates
34
+ possible_rules = select_possible_rules issue_rules_by_active_date
35
+
36
+ data_sets = possible_rules.collect do |grouping_rule|
37
+ make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date
38
+ end
39
+
40
+ wrap_and_render(binding, __FILE__)
41
+ end
42
+
43
+ def default_header_text = 'Daily WIP'
44
+ def default_description_text = ''
45
+
46
+ def default_grouping_rules issue:, rules: # rubocop:disable Lint/UnusedMethodArgument
47
+ raise 'If you use this class directly then you must provide grouping_rules'
48
+ end
49
+
50
+ def select_possible_rules issue_rules_by_active_date
51
+ possible_rules = []
52
+ issue_rules_by_active_date.each_pair do |_date, issues_rules_list|
53
+ issues_rules_list.each do |_issue, rules|
54
+ possible_rules << rules unless possible_rules.any? { |r| r.group == rules.group }
55
+ end
56
+ end
57
+ possible_rules.sort_by!(&:group_priority)
58
+ end
59
+
60
+ def group_issues_by_active_dates
61
+ hash = {}
62
+
63
+ @issues.each do |issue|
64
+ cycletime = issue.board.cycletime
65
+ start = cycletime.started_time(issue)&.to_date
66
+ stop = cycletime.stopped_time(issue)&.to_date
67
+ next if start.nil? && stop.nil?
68
+
69
+ # If it stopped but never started then assume it started at creation so the data points
70
+ # will be available for the config.
71
+ start = issue.created.to_date if stop && start.nil?
72
+ start = @date_range.begin if start < @date_range.begin
73
+
74
+ start.upto(stop || @date_range.end) do |date|
75
+ rule = configure_rule issue: issue, date: date
76
+ (hash[date] ||= []) << [issue, rule] unless rule.ignored?
77
+ end
78
+ end
79
+ hash
80
+ end
81
+
82
+ def make_data_set grouping_rule:, issue_rules_by_active_date:
83
+ positive = grouping_rule.group_priority >= 0
84
+
85
+ data = issue_rules_by_active_date.collect do |date, issue_rules|
86
+ # issues = []
87
+ issue_strings = issue_rules
88
+ .select { |_issue, rules| rules.group == grouping_rule.group }
89
+ .sort_by { |issue, _rules| issue.key_as_i }
90
+ .collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
91
+ title = ["#{grouping_rule.label} (#{label_issues issue_strings.size})"] + issue_strings
92
+
93
+ {
94
+ x: date,
95
+ y: positive ? issue_strings.size : -issue_strings.size,
96
+ title: title
97
+ }
98
+ end
99
+
100
+ {
101
+ type: 'bar',
102
+ label: grouping_rule.label,
103
+ data: data,
104
+ backgroundColor: grouping_rule.color || random_color,
105
+ borderColor: 'gray',
106
+ borderWidth: grouping_rule.color == 'white' ? 1 : 0,
107
+ borderRadius: positive ? 0 : 5
108
+ }
109
+ end
110
+
111
+ def configure_rule issue:, date:
112
+ raise 'grouping_rules must be set' if @group_by_block.nil?
113
+
114
+ rules = DailyGroupingRules.new
115
+ rules.current_date = date
116
+ @group_by_block.call issue, rules
117
+ rules
118
+ end
119
+
120
+ def grouping_rules &block
121
+ @group_by_block = block
122
+ end
123
+ end