Anything the built-in charts can do, you can do yourself. If the report is missing a view you want, or you need one that is specific to how your organization works, you can write it as a chart and call it exactly like any of the ones that ship with the tool.
How a chart gets found
Any class that extends ChartBase can be called from inside html_report by its name in snake_case. Nothing needs to be registered; the name is the wiring.
So given a class like this, in a file of your own:
require 'jirametrics/chart_base'
class FlowMetricsSummary < ChartBase
# ...
end
it is called like any chart that ships with the tool:
html_report do
cycletime_scatterplot # one of the built-in charts
flow_metrics_summary # yours, found by the name of the class above
end
The conversion is a plain capitalise-and-join on each underscore-separated word, so an acronym in the name needs the same treatment as any other word. csv_summary looks for CsvSummary, never CSVSummary.
Loading your file
The name only resolves if the class has already been loaded, so your config has to require your file. This is the step most easily missed, and the error when you miss it points at the call rather than at the cause: you get an undefined method for the chart you just tried to use, as though the feature did not exist.
require './flow_metrics_summary'
Put that at the top of your config file, and note the ./. Ruby’s require searches the load path rather than the directory you happen to be in, so a file sitting beside your config will not be found by a plain require 'flow_metrics_summary'.
What a chart has to provide
Two methods.
initialize takes the configuration block, sets up whatever defaults it wants, and ends by calling instance_eval on that block. That last step is what lets the user’s block call back into your object, so anything you want them to be able to configure is simply a method on the class.
run returns the HTML to drop into the report.
require 'jirametrics/chart_base'
class FlowMetricsSummary < ChartBase
def initialize block
super()
header_text 'Flow Metrics Summary'
description_text '<div class="p">Headline numbers for this period.</div>'
@show_totals = true
instance_eval(&block)
end
# Anything the config block should be able to set is just a method.
def show_totals value
@show_totals = value
end
def run
render_top_text + seam_start + build_table + seam_end
end
end
The block is optional. With one, the settings you defined are available inside it:
html_report do
flow_metrics_summary do
show_totals false
end
end
What you have to work with
By the time run is called, the chart has already been given everything it needs. The ones you are most likely to want:
issues |
Every issue in the report, after any exclusions have been applied |
date_range |
The period being reported on, as a range of dates |
time_range |
The same period, as a range of times |
settings |
The project settings |
all_boards and board_id |
The boards in play, and which one this chart is for |
holiday_dates |
So you can skip non-working days |
To get an issue’s start and finish, call cycletime_for_issue(issue).started_stopped_times(issue) rather than reading a resolution date directly. That routes through the project’s cycletime configuration, which is what every built-in chart uses, so your numbers will agree with the rest of the report instead of quietly disagreeing with it.
Fitting in with the rest of the report
render_top_text emits the header and description you set up in initialize, so most charts start their run with it.
seam_start and seam_end wrap your HTML in the marker comments that stitching uses to pull individual charts out of a finished report. Include them even if you never stitch.
render_no_data returns the message shown when a chart has nothing to say. Return it from run when there is nothing worth drawing.
Use <table class='standard'> for tables and yours will match the others, in both light and dark mode.
If your class defines its own board_id= then it will be run once per board, and the description will be shown only on the first. Otherwise it runs once.
Drawing a chart rather than a table
The example above builds its HTML directly because a table is simple enough not to need a template. For a Chart.js chart you will want an .erb template instead, and there are two ways to get one.
Reusing a built-in chart’s template
If what you want is an existing chart fed with different data, subclass it and override the parts that produce the data. run renders the template belonging to the file it is defined in, so a subclass that does not define its own run renders its parent’s template and you get the whole chart for free.
require 'jirametrics/time_based_scatterplot'
class DeploymentScatterplot < TimeBasedScatterplot
# override the data methods; the parent's run and template do the rest
end
Writing your own template
For something that is not a variation on an existing chart, write your own .erb beside your .rb and tell the chart where to look. render looks for a template with the same basename as the class’s file, in whatever directory html_directory returns, so override that method to point at your own.
require 'jirametrics/chart_base'
class DemoScatter < ChartBase
def initialize block
super()
header_text 'Demo Scatter'
description_text '<div class="p">A Chart.js chart from an extension.</div>'
instance_eval(&block)
end
# Point render at this file's own directory, so it finds demo_scatter.erb beside this .rb
def html_directory
File.dirname(File.realpath(__FILE__))
end
def data_sets
[{ label: 'Demo', data: issues.map { |issue| { x: issue.key, y: 1 } } }]
end
def run
wrap_and_render(binding, __FILE__)
end
end
The matching demo_scatter.erb:
<%= seam_start %>
<div class="chart">
<canvas id="<%= chart_id %>" width="800" height="400"></canvas>
</div>
<script>
new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
type: 'scatter',
data: { datasets: <%= JSON.generate(data_sets) %> }
});
</script>
<%= seam_end %>
Two things about the template are worth knowing. The binding you pass to wrap_and_render is the one the template is evaluated in, so every method on your chart is callable from inside it, which is why data_sets works above. And chart_id is set for you, with a number that increments across the report, so several copies of your chart on one page will not collide. Always use it for element ids.
Chart.js, its time adapter and the annotation plugin are already loaded by the report itself, so your template can go straight to new Chart(...) without adding any script tags of its own.
A complete example
This one summarizes the flow metrics for the period, split by issue type. It shows the pieces above in a realistic shape: configuration through the block, metrics taken through the cycletime configuration, and a table that matches the rest of the report.
Note that the thresholds are opt in. Configure none of them and the table reports the numbers without grading them, which is usually what you want until you have enough history to know what good looks like for your team.
# frozen_string_literal: true
require 'jirametrics/chart_base'
# An example of a custom chart. Any class that extends ChartBase can be called from inside
# html_report by its name in snake_case, so this one is invoked as `flow_metrics_summary`. See
# https://jirametrics.org/config/project/#custom-charts
#
# Summarizes the flow metrics per issue type, so a reader can see the shape of the period before
# scrolling into the charts themselves. These are operational measures taken from the system, not
# business outcomes, so read them as a description of how work moved rather than as a scorecard.
#
# html_report do
# flow_metrics_summary do
# cycle_time_thresholds good: 10, warning: 20
# throughput_thresholds good: 20, warning: 10
# end
# end
class FlowMetricsSummary < ChartBase
SECONDS_PER_DAY = 24 * 60 * 60
DOT_COLORS = { 'good' => '#009E73', 'warning' => '#F0E442', 'bad' => '#D55E00' }.freeze
def initialize block
super()
header_text 'Flow Metrics Summary'
description_text <<~HTML
<div class="p">
Headline numbers for this period, split by issue type. Cycle time and flow efficiency are
averaged across the items that completed inside the report's date range, so a type with
only one or two completions will move around a lot between reports.
</div>
HTML
@thresholds = {}
instance_eval(&block)
end
# Thresholds are opt in. Set none and the table reports numbers without grading them, which is
# usually what you want until you have enough history to know what good looks like for this team.
# For cycle time and WIP a lower number is better, so `good` is the smaller of the two.
def cycle_time_thresholds good:, warning:
@thresholds[:cycle_time] = { good: good, warning: warning, lower_is_better: true }
end
def wip_thresholds good:, warning:
@thresholds[:wip] = { good: good, warning: warning, lower_is_better: true }
end
def throughput_thresholds good:, warning:
@thresholds[:throughput] = { good: good, warning: warning, lower_is_better: false }
end
def flow_efficiency_thresholds good:, warning:
@thresholds[:flow_efficiency] = { good: good, warning: warning, lower_is_better: false }
end
def run
rows = [summarize('All', issues)]
issues.filter_map(&:type).uniq.sort.each do |type|
rows << summarize(type, issues.select { |issue| issue.type == type })
end
return render_no_data if rows.all? { |row| row[:throughput].zero? && row[:wip].zero? }
render_top_text + seam_start + render_table(rows) + seam_end
end
private
def summarize type, issues_of_type
completed = issues_of_type.select { |issue| completed_in_range?(issue) }
{
type: type,
wip: issues_of_type.count { |issue| in_progress?(issue) },
throughput: completed.size,
cycle_time: average(completed.filter_map { |issue| cycle_time_in_days(issue) }),
flow_efficiency: average(completed.filter_map { |issue| flow_efficiency_percent(issue) })
}
end
# Deliberately the project's own cycletime configuration rather than a resolution date, so these
# numbers agree with every other chart in the same report.
def started_stopped issue
cycletime_for_issue(issue).started_stopped_times(issue)
end
def completed_in_range? issue
_started, stopped = started_stopped(issue)
stopped && date_range.include?(stopped.to_date)
end
def in_progress? issue
started, stopped = started_stopped(issue)
started && stopped.nil?
end
def cycle_time_in_days issue
started, stopped = started_stopped(issue)
return nil unless started && stopped
(stopped - started) / SECONDS_PER_DAY
end
def flow_efficiency_percent issue
_started, stopped = started_stopped(issue)
return nil unless stopped
active, total = issue.flow_efficiency_numbers(end_time: stopped)
return nil if total.zero?
active / total * 100.0
end
def average values
return nil if values.empty?
values.sum / values.size.to_f
end
# Returns 'good', 'warning', 'bad', or nil when no threshold was configured for this metric.
def rating key, value
threshold = @thresholds[key]
return nil if threshold.nil? || value.nil?
good, warning = threshold.values_at(:good, :warning)
if threshold[:lower_is_better]
rate value, good: ->(v) { v <= good }, warning: ->(v) { v <= warning }
else
rate value, good: ->(v) { v >= good }, warning: ->(v) { v >= warning }
end
end
def rate value, good:, warning:
return 'good' if good.call(value)
return 'warning' if warning.call(value)
'bad'
end
def dot rating
return '' if rating.nil?
"<span title='#{rating}' style=\"color: #{DOT_COLORS[rating]}\">●</span> "
end
def render_table rows
html = +"<table class='standard'>\n<thead><tr>"
['Issue type', 'WIP', 'Completed', 'Cycle time (days)', 'Flow efficiency'].each do |heading|
html << "<th>#{heading}</th>"
end
html << "</tr></thead>\n<tbody>\n"
rows.each { |row| html << render_row(row) }
html << "</tbody>\n</table>\n"
end
def render_row row
cells = [
row[:type],
"#{dot rating(:wip, row[:wip])}#{row[:wip]}",
"#{dot rating(:throughput, row[:throughput])}#{row[:throughput]}",
"#{dot rating(:cycle_time, row[:cycle_time])}#{format_number row[:cycle_time]}",
"#{dot rating(:flow_efficiency, row[:flow_efficiency])}#{format_number row[:flow_efficiency], suffix: '%'}"
]
"<tr>#{cells.map { |cell| "<td>#{cell}</td>" }.join}</tr>\n"
end
def format_number value, suffix: ''
return '—' if value.nil?
"#{value.round(1)}#{suffix}"
end
end
Save that as flow_metrics_summary.rb beside your config, then:
require './flow_metrics_summary'
html_report do
flow_metrics_summary do
cycle_time_thresholds good: 10, warning: 20
throughput_thresholds good: 20, warning: 10
end
end