Class: ChartBase

Inherits:
Object
  • Object
show all
Defined in:
lib/jirametrics/chart_base.rb

Constant Summary collapse

LABEL_POSITIONS =
%w[5% 25% 45% 65%].freeze
@@chart_counter =
0

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeChartBase

Returns a new instance of ChartBase.



12
13
14
15
16
17
18
19
20
21
22
23
# File 'lib/jirametrics/chart_base.rb', line 12

def initialize
  @chart_colors = {
    'Story'  => CssVariable['--type-story-color'],
    'Task'   => CssVariable['--type-task-color'],
    'Bug'    => CssVariable['--type-bug-color'],
    'Defect' => CssVariable['--type-bug-color'],
    'Spike'  => CssVariable['--type-spike-color']
  }
  @canvas_width = 800
  @canvas_height = 200
  @canvas_responsive = true
end

Instance Attribute Details

#aggregated_project=(value) ⇒ Object (writeonly)

Sets the attribute aggregated_project

Parameters:

  • value

    the value to set the attribute aggregated_project to.



7
8
9
# File 'lib/jirametrics/chart_base.rb', line 7

def aggregated_project=(value)
  @aggregated_project = value
end

#all_boardsObject

Returns the value of attribute all_boards.



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

def all_boards
  @all_boards
end

#atlassian_document_formatObject

Returns the value of attribute atlassian_document_format.



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

def atlassian_document_format
  @atlassian_document_format
end

#board_idObject

Returns the value of attribute board_id.



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

def board_id
  @board_id
end

#canvas_heightObject (readonly)

Returns the value of attribute canvas_height.



8
9
10
# File 'lib/jirametrics/chart_base.rb', line 8

def canvas_height
  @canvas_height
end

#canvas_widthObject (readonly)

Returns the value of attribute canvas_width.



8
9
10
# File 'lib/jirametrics/chart_base.rb', line 8

def canvas_width
  @canvas_width
end

#data_qualityObject

Returns the value of attribute data_quality.



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

def data_quality
  @data_quality
end

#date_rangeObject

Returns the value of attribute date_range.



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

def date_range
  @date_range
end

#file_systemObject

Returns the value of attribute file_system.



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

def file_system
  @file_system
end

#fix_versionsObject

Returns the value of attribute fix_versions.



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

def fix_versions
  @fix_versions
end

#holiday_datesObject

Returns the value of attribute holiday_dates.



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

def holiday_dates
  @holiday_dates
end

#issuesObject

Returns the value of attribute issues.



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

def issues
  @issues
end

#settingsObject

Returns the value of attribute settings.



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

def settings
  @settings
end

#time_rangeObject

Returns the value of attribute time_range.



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

def time_range
  @time_range
end

#timezone_offsetObject

Returns the value of attribute timezone_offset.



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

def timezone_offset
  @timezone_offset
end

#x_axis_titleObject

Returns the value of attribute x_axis_title.



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

def x_axis_title
  @x_axis_title
end

#y_axis_titleObject

Returns the value of attribute y_axis_title.



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

def y_axis_title
  @y_axis_title
end

Instance Method Details

#aggregated_project?Boolean

Returns:

  • (Boolean)


33
34
35
# File 'lib/jirametrics/chart_base.rb', line 33

def aggregated_project?
  @aggregated_project
end

#before_runObject



29
30
31
# File 'lib/jirametrics/chart_base.rb', line 29

def before_run
  @call_before_run_procs&.each { |proc| proc.call }
end

#call_before_run(&proc) ⇒ Object



25
26
27
# File 'lib/jirametrics/chart_base.rb', line 25

def call_before_run &proc
  (@call_before_run_procs ||= []) << proc
end

#canvas(width:, height:, responsive: true) ⇒ Object



334
335
336
337
338
# File 'lib/jirametrics/chart_base.rb', line 334

def canvas width:, height:, responsive: true
  @canvas_width = width
  @canvas_height = height
  @canvas_responsive = responsive
end

#canvas_responsive?Boolean

Returns:

  • (Boolean)


340
341
342
# File 'lib/jirametrics/chart_base.rb', line 340

def canvas_responsive?
  @canvas_responsive
end

#chart_format(object) ⇒ Object



251
252
253
254
255
256
257
258
# File 'lib/jirametrics/chart_base.rb', line 251

def chart_format object
  if object.is_a? Time
    # "2022-04-09T11:38:30-07:00"
    object.strftime '%Y-%m-%dT%H:%M:%S%z'
  else
    object.to_s
  end
end

#collapsible_issues_panel(issue_descriptions, *args) ⇒ Object



129
130
131
132
133
134
135
136
# File 'lib/jirametrics/chart_base.rb', line 129

def collapsible_issues_panel issue_descriptions, *args
  link_id = next_id
  issues_id = next_id

  issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
  erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
  erb.result(binding)
end

#color_block(color, title: nil) ⇒ Object



344
345
346
347
348
349
350
351
352
353
# File 'lib/jirametrics/chart_base.rb', line 344

def color_block color, title: nil
  result = +''
  result << "<div class='color_block' style='"
  result << "background: #{CssVariable[color]};" if color
  result << 'visibility: hidden;' unless color
  result << "'"
  result << " title=#{title.inspect}" if title
  result << '></div>'
  result
end

#color_for(type:) ⇒ Object



73
74
75
# File 'lib/jirametrics/chart_base.rb', line 73

def color_for type:
  @chart_colors[type] ||= random_color
end

#completed_issues_in_range(include_unstarted: false) ⇒ Object



240
241
242
243
244
245
246
247
248
249
# File 'lib/jirametrics/chart_base.rb', line 240

def completed_issues_in_range include_unstarted: false
  issues.select do |issue|
    cycletime = issue.board.cycletime
    started_time, stopped_time = cycletime.started_stopped_times(issue)

    stopped_time &&
      date_range.include?(stopped_time.to_date) && # Remove outside range
      (include_unstarted || (started_time && (stopped_time >= started_time)))
  end
end

#current_boardObject

Return only the board columns for the current board.



225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/jirametrics/chart_base.rb', line 225

def current_board
  if @board_id.nil?
    case @all_boards.size
    when 0
      raise 'Couldn\'t find any board configurations. Ensure one is set'
    when 1
      return @all_boards.values[0]
    else
      raise "Must set board_id so we know which to use. Multiple boards found: #{@all_boards.keys.inspect}"
    end
  end

  @all_boards[@board_id]
end

#cycletime(&block) ⇒ Object

Set a cycletime for just this one chart, overriding the one for the report.



365
366
367
368
369
370
371
372
# File 'lib/jirametrics/chart_base.rb', line 365

def cycletime &block
  call_before_run do
    @cycletime = CycleTimeConfig.new(
      possible_statuses: possible_statuses, label: nil, block: block, file_system: file_system,
      settings: settings
    )
  end
end

#cycletime_for_issue(issue) ⇒ Object

Returns the cycletime in use right now, which may be specific to the chart or across the report.



375
376
377
# File 'lib/jirametrics/chart_base.rb', line 375

def cycletime_for_issue issue
  @cycletime || issue.board.cycletime
end

#daily_chart_dataset(date_issues_list:, color:, label:, positive: true) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/jirametrics/chart_base.rb', line 103

def daily_chart_dataset date_issues_list:, color:, label:, positive: true
  {
    type: 'bar',
    label: label,
    data: date_issues_list.collect do |date, issues|
      issues.sort_by!(&:key_as_i)
      title = "#{label} (#{label_issues issues.size})"
      {
        x: date,
        y: positive ? issues.size : -issues.size,
        title: [title] + issues.collect { |i| "#{i.key} : #{i.summary.strip}#{" #{yield date, i}" if block_given?}" }
      }
    end,
    backgroundColor: color,
    borderRadius: positive ? 0 : 5
  }
end

#date_annotationObject



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/jirametrics/chart_base.rb', line 176

def date_annotation
  annotations = settings['date_annotations'] || []
  in_range = annotations
    .map { |a| [a, normalize_annotation_datetime(a['date'])] }
    .select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
    .sort_by { |(_, dt)| dt }

  positions = stagger_label_positions(in_range.map { |(_, dt)| dt })

  in_range.each_with_index.collect do |(a, normalized), index|
    <<~TEXT
      dateAnnotation#{index}: {
        type: 'line',
        xMin: #{normalized.to_json},
        xMax: #{normalized.to_json},
        borderColor: 'rgba(0,0,0,0.7)',
        borderWidth: 1,
        label: {
          display: true,
          content: #{a['label'].to_json},
          position: #{positions[index].to_json}
        }
      },
    TEXT
  end.join
end

#describe_non_working_daysObject



355
356
357
358
359
360
361
362
# File 'lib/jirametrics/chart_base.rb', line 355

def describe_non_working_days
  <<-TEXT
    <div class='p'>
      The #{color_block '--non-working-days-color'} vertical bars indicate non-working days; weekends
      and any other holidays mentioned in the configuration.
    </div>
  TEXT
end

#description_text(text = :none) ⇒ Object



265
266
267
268
# File 'lib/jirametrics/chart_base.rb', line 265

def description_text text = :none
  @description_text = text unless text == :none
  @description_text
end

#format_integer(number) ⇒ Object

Convert a number like 1234567 into the string “1,234,567”



271
272
273
# File 'lib/jirametrics/chart_base.rb', line 271

def format_integer number
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
end

#format_status(object, board:, is_category: false, use_old_status: false) ⇒ Object

object will be either a Status or a ChangeItem if it’s a ChangeItem then use_old_status will specify whether we’re using the new or old Either way, is_category will format the category rather than the status



278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/jirametrics/chart_base.rb', line 278

def format_status object, board:, is_category: false, use_old_status: false
  status = nil
  error_message = nil

  case object
  when ChangeItem
    id = use_old_status ? object.old_value_id : object.value_id
    status = board.possible_statuses.find_by_id(id)
    if status.nil?
      error_message = use_old_status ? object.old_value : object.value
    end
  when Status
    status = object
  else
    raise "Unexpected type: #{object.class}"
  end

  return "<span style='color: red'>#{error_message}</span>" if error_message

  color = status_category_color status

  visibility = ''
  if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
    visibility = icon_span(
      title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
      icon: ' 👀'
    )
  end
  text = is_category ? status.category : status
  "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
end

#header_text(text = :none) ⇒ Object



260
261
262
263
# File 'lib/jirametrics/chart_base.rb', line 260

def header_text text = :none
  @header_text = text unless text == :none
  @header_text
end

#holidays(date_range: @date_range) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/jirametrics/chart_base.rb', line 138

def holidays date_range: @date_range
  result = []
  start_date = nil
  end_date = nil

  date_range.each do |date|
    if date.saturday? || date.sunday? || holiday_dates.include?(date)
      if start_date.nil?
        start_date = date
      else
        end_date = date
      end
    elsif start_date
      result << (start_date..(end_date || start_date))
      start_date = nil
      end_date = nil
    end
  end
  result
end

#html_directoryObject



37
38
39
40
# File 'lib/jirametrics/chart_base.rb', line 37

def html_directory
  pathname = Pathname.new(File.realpath(__FILE__))
  "#{pathname.dirname}/html"
end

#icon_span(title:, icon:) ⇒ Object



310
311
312
# File 'lib/jirametrics/chart_base.rb', line 310

def icon_span title:, icon:
  "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
end

#label_days(days) ⇒ Object



77
78
79
80
81
# File 'lib/jirametrics/chart_base.rb', line 77

def label_days days
  return 'unknown' if days.nil?

  "#{days} day#{'s' unless days == 1}"
end

#label_hours(hours) ⇒ Object



83
84
85
86
87
# File 'lib/jirametrics/chart_base.rb', line 83

def label_hours hours
  return 'unknown' if hours.nil?

  "#{hours} hour#{'s' unless hours == 1}"
end

#label_issues(count) ⇒ Object



95
96
97
# File 'lib/jirametrics/chart_base.rb', line 95

def label_issues count
  "#{count} issue#{'s' unless count == 1}"
end

#label_minutes(minutes) ⇒ Object



89
90
91
92
93
# File 'lib/jirametrics/chart_base.rb', line 89

def label_minutes minutes
  return 'unknown' if minutes.nil?

  "#{minutes} minute#{'s' unless minutes == 1}"
end


121
122
123
124
125
126
127
# File 'lib/jirametrics/chart_base.rb', line 121

def link_to_issue issue, args = {}
  attributes = { class: 'issue_key' }
    .merge(args)
    .collect { |key, value| "#{key}='#{value}'" }
    .join(' ')
  "<a href='#{issue.url}' #{attributes}>#{issue.key}</a>"
end

#next_idObject



69
70
71
# File 'lib/jirametrics/chart_base.rb', line 69

def next_id
  @@chart_counter += 1
end

#normalize_annotation_datetime(value) ⇒ Object



215
216
217
218
219
220
221
222
# File 'lib/jirametrics/chart_base.rb', line 215

def normalize_annotation_datetime value
  offset = timezone_offset || '+00:00'
  if value.include?('T')
    value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
  else
    "#{value}T00:00:00#{offset}"
  end
end

#not_visible_text(issue) ⇒ Object



314
315
316
317
318
319
# File 'lib/jirametrics/chart_base.rb', line 314

def not_visible_text issue
  reasons = issue.reasons_not_visible_on_board
  return nil if reasons.empty?

  "<span style='background: var(--warning-banner)'>Not visible on board: #{reasons.join(', ')}</span>"
end

#random_colorObject



330
331
332
# File 'lib/jirametrics/chart_base.rb', line 330

def random_color
  "##{Random.bytes(3).unpack1('H*')}"
end

#render(caller_binding, file) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
# File 'lib/jirametrics/chart_base.rb', line 42

def render caller_binding, file
  pathname = Pathname.new(File.realpath(file))
  basename = pathname.basename.to_s
  raise "Unexpected filename #{basename.inspect}" unless basename =~ /^(.+)\.rb$/

  # Insert a incrementing chart_id so that all the chart names on the page are unique
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3

  erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
  erb.result(caller_binding)
end

#render_axis_title(axis_direction) ⇒ Object



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/jirametrics/chart_base.rb', line 387

def render_axis_title axis_direction
  text = case axis_direction
  when :x
    x_axis_title
  when :y
    y_axis_title
  else
    raise "Unexpected axis_direction: #{axis_direction}"
  end
  return '' unless text

  <<~CONTENT
    title: {
        display: true,
        text: "#{text}"
      },
  CONTENT
end

#render_top_text(caller_binding) ⇒ Object



54
55
56
57
58
59
# File 'lib/jirametrics/chart_base.rb', line 54

def render_top_text caller_binding
  result = +''
  result << "<h1 class='foldable'>#{@header_text}</h1>" if @header_text
  result << ERB.new(@description_text).result(caller_binding) if @description_text
  result
end

#seam_end(type = 'chart') ⇒ Object



383
384
385
# File 'lib/jirametrics/chart_base.rb', line 383

def seam_end type = 'chart'
  "\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
end

#seam_start(type = 'chart') ⇒ Object



379
380
381
# File 'lib/jirametrics/chart_base.rb', line 379

def seam_start type = 'chart'
  "\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
end

#stagger_label_positions(datetimes) ⇒ Object



203
204
205
206
207
208
209
210
211
212
213
# File 'lib/jirametrics/chart_base.rb', line 203

def stagger_label_positions datetimes
  return [] if datetimes.empty?

  threshold_days = (date_range.end - date_range.begin).to_f / 5.0
  slot = 0
  [LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
    days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
    slot = days_apart < threshold_days ? slot + 1 : 0
    LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
  end
end

#status_category_color(status) ⇒ Object



321
322
323
324
325
326
327
328
# File 'lib/jirametrics/chart_base.rb', line 321

def status_category_color status
  case status.category.key
  when 'new' then CssVariable['--status-category-todo-color']
  when 'indeterminate' then CssVariable['--status-category-inprogress-color']
  when 'done' then CssVariable['--status-category-done-color']
  else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
  end
end

#to_human_readable(number) ⇒ Object



99
100
101
# File 'lib/jirametrics/chart_base.rb', line 99

def to_human_readable number
  number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
end

#working_days_annotationObject



159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/jirametrics/chart_base.rb', line 159

def working_days_annotation
  holidays.each_with_index.collect do |range, index|
    <<~TEXT
      holiday#{index}: {
        drawTime: 'beforeDraw',
        type: 'box',
        xMin: '#{range.begin}T00:00:00',
        xMax: '#{range.end}T23:59:59',
        backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
        borderColor: #{CssVariable.new('--non-working-days-color').to_json}
      },
    TEXT
  end.join
end

#wrap_and_render(caller_binding, file) ⇒ Object

Render the file and then wrap it with standard headers and quality checks.



62
63
64
65
66
67
# File 'lib/jirametrics/chart_base.rb', line 62

def wrap_and_render caller_binding, file
  result = +''
  result << render_top_text(caller_binding)
  result << render(caller_binding, file)
  result
end