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]}\">&#9679;</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 '&mdash;' 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