Skip to content

Lesson 6 — Map-driven sidebars

The choropleths from Lessons 4 and 5 are displays. The reader looks at colours and reads the gist. A popup gives a small detail spike on click. But beyond that, there’s no way to drill in.

Real reporting work isn’t just looking. It’s interrogating. “That SA is amber on SLA — what’s actually happening there? Is it trending up or down? Is it an outlier within its depot, or is the whole depot struggling?” The choropleth surfaces the question; another tool has to answer it.

This lesson builds that other tool: a new manager report called Service Area Detail. The map looks similar to Lesson 5’s SLA performance map — same divergent ramp, same SLA semantics — but the click behaviour is different. Clicking an SA dispatches a Turbo Stream that replaces the contents of a fixed sidebar with rich detail — trend sparklines, priority breakdown, depot comparison. The map becomes a navigator into the detail, not a destination in itself.

The lesson teaches three new things:

  • Vera’s on_click: { turbo_stream: ... } — declaring that layer clicks should dispatch a Turbo Stream request rather than open a popup
  • Turbo Streams as the page-update primitive for click- driven UI — server returns stream actions, page applies them
  • Aggregation queries that return chart-shaped data — weekly time-bucketed counts, ready for sparkline rendering

By the end, the manager has a third report — and a different interaction pattern in the dispatch deck’s vocabulary.

Why a new report instead of upgrading Lesson 5

Lesson 5’s SLA choropleth uses click-to-popup. The popup gives a quick spike of detail — “On-time: 87.3% (487 of 517)” — which is exactly the right detail for a quick glance.

Sidebars and popups are different tools for different questions. A popup answers what’s this? — small, fast, glance-and-dismiss. A sidebar answers tell me about this — panel-shaped, contextual, click-and-explore.

This lesson treats them as alternatives, not replacements. Lesson 5 stays as it is — a complete, working report. This lesson builds a different report that uses the sidebar pattern. The reader ends up with both interaction idioms in their toolkit, and gains an intuition for when each fits.

A rough rubric for choosing:

Reach for a popup when:

  • The answer fits in 200×150 pixels comfortably
  • The user expects to glance and move on
  • The map’s geographic context matters more than the detail
  • The user might click many features in quick succession

Reach for a sidebar when:

  • The answer wants a full panel — multiple sections, charts, lists
  • The user expects to dwell on one selection
  • The detail is the primary interaction; the map is the navigator
  • Multiple data dimensions can usefully share the same selection context

Service Area Detail fits the sidebar pattern because the useful detail is multi-dimensional — trends, breakdowns, comparisons. None of those individually fit a popup, and together they earn a full panel.

What we’re building

A new page in the manager’s reports section:

+----------------------------------+--------------+
| Service Area Detail              |  SA panel   |
|                                  |              |
|  ┌──────────────────────────┐    |  Bondi -     |
|  │                          │    |  Bondi Jct.  |
|  │        SLA map           │    |              |
|  │                          │    |  1,247 jobs  |
|  │   (click an SA)          │    |  ──╱╲╱╲──    |
|  │                          │    |              |
|  └──────────────────────────┘    |  87.3% OT    |
|                                  |  ──╲──╱──    |
|                                  |              |
|                                  |  ▓▓▓░░ ...   |
+----------------------------------+--------------+

When the page first loads, the sidebar shows a placeholder (“Click a service area to see details”). Clicking an SA dispatches a request; the server responds with a Turbo Stream that replaces the sidebar’s contents with the detail panel. Clicking a different SA replaces the contents again. No page reload, no manual JavaScript.

How on_click with turbo_stream works

Vera’s on_click: is a layer-level option (alongside paint:, popup:, etc.) that wires click behaviour. The turbo_stream: variant dispatches a Turbo Stream request:

1
2
3
4
5
m.layer :sa_fill, source: :sa_sla, type: :fill,
        # ... paint and other config
        on_click: {
          turbo_stream: "/reports/service_area_detail/panel/:id"
        }

When a click happens:

  1. Vera resolves the URL — :id gets substituted with the clicked feature’s id property at click time. (Rails- flavour placeholders. :property_name matches any feature property by name.)
  2. Vera fires a fetch request to that URL with the Accept: text/vnd.turbo-stream.html header.
  3. The server responds with a Turbo Stream document — a sequence of stream actions like replace, append, update.
  4. Turbo applies the stream actions to the page. Elements targeted by id get updated in place.

The mechanism is elegant: the map declares “clicks dispatch a stream”; the server returns stream actions; the page auto-updates. No JavaScript on your part, no Turbo Frame wrapping, no DOM relationships to manage.

Note that popup: and on_click: would compete for the same click event. A layer can have one or the other; we pick the interaction model that fits the report.

Streams vs frames — when each fits

The Vera gem also supports popup: { turbo_frame: ... }, which uses Turbo Frames inside MapLibre popups. So why streams here, not frames?

Turbo Frames are scoped DOM regions. A <turbo-frame id="x"> element can be replaced by a server response that also contains a <turbo-frame id="x">. Frames are great for self-contained UI regions that update independently — a search results pane, a comment thread, a popup body.

Turbo Streams are general-purpose page updates. The server returns instructions like “replace the element with id ‘sa-detail’” — and Turbo finds and updates that element wherever it is on the page. Streams are right for updates that aren’t naturally bounded to one region — broadcast notifications, multi-element updates, sidebar replacements driven by interactions elsewhere.

For our case the sidebar is a fixed page region updated by a click on a different page region (the map). Stream is the right tool. A Frame would also work but would require the sidebar to be wrapped as a Frame, and the server response to include a matching Frame element. Stream is more direct.

The data the sidebar needs

Designing the panel before the queries. For each SA, useful detail at a glance:

  • Identity — name and SA3 code
  • Volume in the period — total completed in the last 90 days, plus a weekly sparkline showing the trend
  • SLA performance — overall on-time percentage, plus a weekly sparkline showing whether it’s stable or sliding
  • Priority breakdown — distribution of urgent / high / normal / low across completed jobs
  • Depot comparison — this SA’s volume and SLA, alongside the depot’s average, so the reader can tell whether the SA is an outlier or typical

That’s five sections. Each is a small query against the same data the choropleths already aggregate; the new work is shaping the results to feed sparklines and comparison rows.

The summary service

Create app/services/sa_detail.rb:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
module SaDetail
  extend self

  WINDOW_DAYS = 90

  def call(sa)
    {
      identity:           identity_for(sa),
      volume_summary:     volume_summary_for(sa),
      volume_trend:       volume_trend_for(sa),
      sla_summary:        sla_summary_for(sa),
      sla_trend:          sla_trend_for(sa),
      priority_breakdown: priority_breakdown_for(sa),
      depot_comparison:   depot_comparison_for(sa)
    }
  end

  private

  def identity_for(sa)
    { name: sa.name, code: sa.code }
  end

  def volume_summary_for(sa)
    completed = completed_window(sa).count
    weeks = (WINDOW_DAYS / 7.0).round(1)
    {
      total:        completed,
      avg_per_week: weeks.zero? ? 0 : (completed / weeks).round
    }
  end

  # Weekly count of completed jobs over the window. Returns an
  # array of integers in chronological order — ready for a
  # sparkline polyline.
  def volume_trend_for(sa)
    bucket = Arel.sql("DATE_TRUNC('week', completed_at)")

    rows = completed_window(sa)
             .group(bucket)
             .order(bucket)
             .count

    rows.values
  end

  def sla_summary_for(sa)
    completed = completed_window(sa).count
    return { on_time_pct: nil, on_time_count: 0, completed: 0 } if completed.zero?

    on_time = completed_window(sa)
                .where("completed_at - created_at <= #{Sla.sql_target('priority')}")
                .count

    {
      on_time_pct:   (on_time.to_f / completed * 100).round(1),
      on_time_count: on_time,
      completed:     completed
    }
  end

  # Weekly on-time percentage over the window. Returns array of
  # floats (0-100). Where a week has zero completed jobs, returns
  # nil for that week — sparkline component renders gaps.
  def sla_trend_for(sa)
    bucket = Arel.sql("DATE_TRUNC('week', completed_at)")

    completed = completed_window(sa)
                  .group(bucket)
                  .order(bucket)
                  .count

    on_time = completed_window(sa)
                .where("completed_at - created_at <= #{Sla.sql_target('priority')}")
                .group(bucket)
                .order(bucket)
                .count

    completed.map { |week, total|
      total.zero? ? nil : (on_time[week].to_i.to_f / total * 100).round(1)
    }
  end

  # Counts of completed jobs by priority over the window.
  # Returns a hash {priority => count} with all four priorities
  # present (zero-filled).
  def priority_breakdown_for(sa)
    counts = completed_window(sa).group(:priority).count
    %w[urgent high normal low].each_with_object({}) { |p, h|
      h[p] = counts[p].to_i
    }
  end

  def depot_comparison_for(sa)
    depot_id = sa.depot_id
    return { volume: nil, on_time_pct: nil } unless depot_id

    sa_ids = ServiceArea.where(depot_id: depot_id).pluck(:id)

    depot_completed = Job.where(service_area_id: sa_ids)
                         .where("completed_at >= ?", WINDOW_DAYS.days.ago)
    total_count = depot_completed.count
    on_time_count = depot_completed
                      .where("completed_at - created_at <= #{Sla.sql_target('priority')}")
                      .count

    avg_volume = sa_ids.any? ? (total_count.to_f / sa_ids.size).round : 0
    avg_pct    = total_count.zero? ? nil : (on_time_count.to_f / total_count * 100).round(1)

    { volume: avg_volume, on_time_pct: avg_pct }
  end

  # Shared filter — completed in the window. ActiveRecord
  # relations chain naturally; each caller adds its own further
  # conditions.
  def completed_window(sa)
    Job.where(service_area_id: sa.id)
       .where("completed_at >= ?", WINDOW_DAYS.days.ago)
  end
end

A few things worth understanding.

completed_window(sa) is a private helper relation. Many of the queries start from “completed jobs in this SA over the window.” Rather than rewriting the same where clauses, the helper returns the relation; each caller chains its own specifics. ActiveRecord lazy evaluation means no SQL runs until .count or similar is called — five callers, five distinct queries with shared setup.

DATE_TRUNC('week', ...) is Postgres’s date-bucketing function. It rounds a timestamp down to the start of its containing week. Combined with GROUP BY and COUNT, it gives the weekly aggregations the sparklines need. Postgres weeks default to Monday-starting (ISO 8601 conventions).

The Arel.sql(...) wrapper is required because Rails 7+ rejects raw SQL fragments in group() and order() by default — a guard against accidental SQL injection from user input. Arel.sql is the explicit “I assert this is safe” escape hatch. The string is hardcoded inside the service module, so there’s no actual injection risk; the wrapper just satisfies Rails’ guard.

The SLA trend can have nil entries. Weeks with zero completed jobs don’t have a denominator; we emit nil rather than fudge a zero. The sparkline component will render gaps.

Depot comparison divides total by SA count, not job-weighted average. “Average SA volume” is a more useful framing than “average job within depot.” It tells the manager whether the clicked SA is a high-volume outlier or a typical performer within its depot.

Run it from console

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
sa = ServiceArea.find_by(name: "Bondi - Bondi Junction")
detail = SaDetail.call(sa)

detail[:volume_summary]
# => { total: 1247, avg_per_week: 96 }

detail[:volume_trend].size
# => 13  (weeks in the 90-day window)

detail[:volume_trend].first(5)
# => [42, 56, 68, 51, 89]

detail[:sla_trend].first(5)
# => [88.1, 91.4, 87.2, 89.6, 92.3]

detail[:priority_breakdown]
# => { "urgent" => 12, "high" => 42, "normal" => 687, "low" => 506 }

detail[:depot_comparison]
# => { volume: 384, on_time_pct: 91.2 }

The sparkline component

A small reusable piece. Create app/components/sparkline.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Components::Sparkline < Components::Base
  prop :values, _Array(_Nilable(_Any))
  prop :width,  Integer, default: -> { 120 }
  prop :height, Integer, default: -> { 30 }
  prop :stroke, String,  default: -> { "currentColor" }

  def view_template
    return empty_state if @values.compact.empty?

    svg(width: @width, height: @height, viewBox: "0 0 #{@width} #{@height}",
        class: "inline-block align-middle") do |svg|
      svg.polyline(
        points: polyline_points,
        fill: "none",
        stroke: @stroke,
        stroke_width: 1.5,
        stroke_linecap: "round",
        stroke_linejoin: "round"
      )
    end
  end

  private

  # Map values onto a polyline path. Skips nil values — the line
  # picks up at the next non-nil. Auto-scales vertically so even
  # low-volume sparklines fill the available height.
  def polyline_points
    valid = @values.each_with_index.reject { |v, _| v.nil? }
    return "" if valid.empty?

    min_val = valid.map(&:first).min.to_f
    max_val = valid.map(&:first).max.to_f
    range   = max_val - min_val

    @values.each_with_index.filter_map { |v, i|
      next nil if v.nil?

      x = (i.to_f / [@values.size - 1, 1].max) * (@width - 2) + 1
      y = if range.zero?
            @height / 2.0
          else
            @height - 1 - ((v - min_val) / range) * (@height - 2)
          end
      "#{x.round(2)},#{y.round(2)}"
    }.join(" ")
  end

  def empty_state
    span(class: "text-xs text-slate-400") { "—" }
  end
end

A couple of decisions worth understanding.

stroke: "currentColor" lets the sparkline inherit text colour from its parent. The component using the sparkline can control its colour with Tailwind classes — text-emerald-600 for SLA, text-slate-600 for volume, etc.

Auto-scaling vertical range. Each sparkline scales to its own min/max, so even a line that ranges from 2 to 9 fills the sparkline’s height. The trade-off: sparklines aren’t directly comparable between SAs (one SA’s “high” might be 200, another’s might be 8). For our purpose — showing trend shape, not absolute magnitude — auto-scaling is the right call. The headline number conveys magnitude.

Nil values produce gaps. filter_map drops the nils; the polyline doesn’t draw segments through them. A reader sees a broken line — accurately representing “no data this week” rather than a falsely-zero dip.

Stroke styling chosen for clarity. stroke_linecap: round and stroke_linejoin: round keep the line visually clean at small sizes. stroke_width: 1.5 is heavier than 1 (which disappears at high density on retina displays) but lighter than 2 (which feels chunky for sparkline scale).

The panel component

Create app/components/reports/sa_detail_panel.rb:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
class Components::Reports::SaDetailPanel < Components::Base
  prop :detail, Hash

  def view_template
    div(class: "p-6 space-y-5") do
      identity_section
      volume_section
      sla_section
      priority_section
      comparison_section
    end
  end

  private

  def identity_section
    div do
      h2(class: "text-lg font-bold text-slate-900") { @detail[:identity][:name] }
      p(class: "text-sm text-slate-500") { "SA3 #{@detail[:identity][:code]}" }
    end
  end

  def volume_section
    summary = @detail[:volume_summary]
    trend   = @detail[:volume_trend]

    div(class: "space-y-1") do
      div(class: "flex items-baseline justify-between") do
        div do
          p(class: "text-2xl font-bold text-slate-900") { summary[:total].to_s }
          p(class: "text-xs text-slate-500") do
            plain "completed (avg #{summary[:avg_per_week]} / week)"
          end
        end
        div(class: "text-slate-600") do
          render Components::Sparkline.new(values: trend, width: 100, height: 28)
        end
      end
    end
  end

  def sla_section
    summary = @detail[:sla_summary]
    trend   = @detail[:sla_trend]

    div(class: "space-y-1") do
      div(class: "flex items-baseline justify-between") do
        div do
          if summary[:on_time_pct].nil?
            p(class: "text-sm text-slate-500") { "No SLA data" }
          else
            p(class: "text-2xl font-bold #{sla_colour_class(summary[:on_time_pct])}") do
              "#{summary[:on_time_pct]}%"
            end
            p(class: "text-xs text-slate-500") do
              plain "on time (#{summary[:on_time_count]} of #{summary[:completed]})"
            end
          end
        end
        if trend.compact.any?
          div(class: sla_colour_class(summary[:on_time_pct])) do
            render Components::Sparkline.new(values: trend, width: 100, height: 28)
          end
        end
      end
    end
  end

  def priority_section
    counts = @detail[:priority_breakdown]
    total  = counts.values.sum
    return if total.zero?

    div do
      h3(class: "text-sm font-semibold text-slate-700 mb-2") { "Priority breakdown" }
      div(class: "space-y-1.5") do
        counts.each do |priority, count|
          priority_row(priority, count, total)
        end
      end
    end
  end

  def priority_row(priority, count, total)
    pct = count.to_f / total * 100

    div(class: "flex items-center gap-2 text-xs") do
      span(class: "w-14 text-slate-600 capitalize") { priority }
      div(class: "flex-1 h-2 bg-slate-100 rounded overflow-hidden") do
        div(class: "h-full #{priority_colour(priority)}",
            style: "width: #{pct.round(1)}%")
      end
      span(class: "w-10 text-right text-slate-700 tabular-nums") { count.to_s }
    end
  end

  def comparison_section
    sa_volume    = @detail[:volume_summary][:total]
    sa_pct       = @detail[:sla_summary][:on_time_pct]
    depot_volume = @detail[:depot_comparison][:volume]
    depot_pct    = @detail[:depot_comparison][:on_time_pct]

    div(class: "pt-3 border-t border-slate-200") do
      h3(class: "text-sm font-semibold text-slate-700 mb-2") { "Compared to depot" }
      div(class: "text-xs text-slate-600 space-y-1") do
        comparison_row("Volume",  sa_volume,        depot_volume)
        comparison_row("On time", sa_pct && "#{sa_pct}%", depot_pct && "#{depot_pct}%")
      end
    end
  end

  def comparison_row(label, sa_value, depot_value)
    div(class: "flex justify-between") do
      span { label }
      span do
        plain "#{sa_value || '—'} "
        span(class: "text-slate-400") { "(depot avg #{depot_value || '—'})" }
      end
    end
  end

  def priority_colour(priority)
    case priority
    when "urgent" then "bg-red-500"
    when "high"   then "bg-amber-500"
    when "normal" then "bg-blue-500"
    when "low"    then "bg-slate-400"
    end
  end

  def sla_colour_class(pct)
    return "text-slate-700" if pct.nil?
    if pct >= 95
      "text-emerald-700"
    elsif pct >= 85
      "text-amber-700"
    else
      "text-red-700"
    end
  end
end

A few things worth a closer look.

The component renders just the panel contents. No turbo_frame_tag wrapper, no surrounding aside. The panel is the inner content; the page’s <aside> element wraps it externally. The Turbo Stream response targets that aside by id and replaces its contents with this rendered component.

The volume and SLA sections have parallel structure. A big number on the left, a sparkline on the right. The reader’s eye gets a quick read at the top (the number) and supporting trend context to the side. Same shape, different metric.

The priority bars are pure HTML. No SVG, no chart library. A flex row per priority with a small horizontal bar. The bar’s width is the percentage of total; the colour is priority-coded.

The comparison row shows for missing data. When the SA has zero completed jobs in the window, both volume and on-time percentage might be missing. The em-dash is a small honest marker rather than collapsing or hiding.

The controller and routes

Add to ReportsController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def service_area_detail
  # Implicit render of app/views/reports/service_area_detail.html.erb
end

def sa_detail_panel
  sa = ServiceArea.find(params[:id])
  @detail = SaDetail.call(sa)

  panel_html = Components::Reports::SaDetailPanel.new(detail: @detail).call

  render turbo_stream: turbo_stream.update("sa-detail", panel_html)
end

A few things to know about the sa_detail_panel action.

render turbo_stream: is Rails’ helper for returning a Turbo Stream response. The HTTP response is Content-Type: text/vnd.turbo-stream.html and contains <turbo-stream> elements that Turbo applies to the page.

turbo_stream.update("sa-detail", panel_html) is the stream-builder for the update action — replace the contents of the element with id sa-detail. Other actions exist (replace, append, prepend, before, after, remove) for different update patterns; update fits this case exactly.

Components::Reports::SaDetailPanel.new(...).call renders the Phlex component to a string. This works because Phlex components render to strings when called; the string is the HTML that becomes the new contents of #sa-detail.

Routes in config/routes.rb:

1
2
3
4
5
6
7
get "/reports/service_area_detail",
    to: "reports#service_area_detail",
    as: :service_area_detail_report

get "/reports/service_area_detail/panel/:id",
    to: "reports#sa_detail_panel",
    as: :sa_detail_panel

The panel route uses :id as a path segment — it’s the URL shape Vera substitutes feature properties into. The map’s on_click config will reference this URL with :id in place.

The sidebar entry

Add to the REPORTS constant in Components::Sidebar:

1
2
3
4
5
REPORTS = [
  { label: "Choropleth Report",   path: "/reports/choropleth",          icon: :chart_bar,        roles: %w[manager] },
  { label: "SLA Performance",     path: "/reports/sla_performance",     icon: :clock,            roles: %w[manager] },
  { label: "Service Area Detail", path: "/reports/service_area_detail", icon: :magnifying_glass, roles: %w[manager] }
].freeze

magnifying_glass for the drill-in idiom. If your icon component doesn’t have it, alternatives are :cursor_arrow_rays, :rectangle_group, or :bars_3.

The map component

Create app/components/reports/sa_detail_map.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class Components::Reports::SaDetailMap < Components::Base
  prop :id,     String, default: -> { "sa-detail-map" }
  prop :height, String, default: -> { "100%" }

  # Same divergent ramp as the SLA performance map. The map's
  # *purpose* is to surface SAs worth drilling into; using the
  # SLA scale gives the reader the same visual vocabulary they
  # learned in Lesson 5.
  COLOUR_RAMP = [
    [50,  "#dc2626"],
    [70,  "#ef4444"],
    [85,  "#f59e0b"],
    [95,  "#10b981"],
    [100, "#059669"]
  ].freeze

  NO_DATA_COLOUR = "#cbd5e1"

  def view_template
    render Vera::Map.new(id: @id, height: @height, style: :voyager,
                         zoom_indicator: :bottom_left, loading_indicator: true) do |m|
      m.control :navigation
      m.control :fullscreen
      m.control :scale, unit: :metric

      add_choropleth(m)
    end
  end

  private

  def add_choropleth(m)
    m.source :sa_detail,
             url:        "/api/service_areas/sla_performance.json",
             fit_bounds: { padding: 20 }

    m.layer :sa_fill, source: :sa_detail, type: :fill,
            paint: {
              fill_color:   choropleth_expression,
              fill_opacity: 0.75
            },
            on_hover: { fill_opacity: 0.9 },
            on_click: {
              turbo_stream: "/reports/service_area_detail/panel/:id"
            }

    m.layer :sa_outline, source: :sa_detail, type: :line,
            paint: {
              line_color:   "#475569",
              line_width:   0.5,
              line_opacity: 0.5
            },
            on_hover: {
              line_color:   "#0f172a",
              line_width:   2,
              line_opacity: 1
            }
  end

  def choropleth_expression
    [
      "case",
      ["==", ["typeof", ["get", "on_time_pct"]], "number"],
      ["interpolate", ["linear"], ["get", "on_time_pct"], *COLOUR_RAMP.flatten],
      NO_DATA_COLOUR
    ]
  end
end

Two things worth noting about this map vs Lesson 5’s:

It reuses /api/service_areas/sla_performance.json. Same endpoint, same data, same paint expression. The new lesson doesn’t need a different report — the question on the map (“where is performance worth investigating?”) is exactly the question Lesson 5’s data already answers.

It has no popup, no legend. Click drives the sidebar. There’s no need for a competing popup, and the legend’s absence is deliberate — Lesson 5’s report includes a legend; this report’s emphasis is the sidebar. (You could add a legend via m.overlay if you wanted; the cleaner default here is “the sidebar tells the story.”)

The on_click: { turbo_stream: ... } is the new piece. The URL string /reports/service_area_detail/panel/:id substitutes the clicked feature’s id property at click time — /reports/service_area_detail/panel/123 for an SA with id 123.

The page view

Create app/views/reports/service_area_detail.html.erb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="h-full flex flex-col">
  <header class="px-6 py-4 border-b border-slate-200">
    <h1 class="text-2xl font-bold text-slate-900">Service Area Detail</h1>
    <p class="text-sm text-slate-500 mt-1">
      Click any service area for a 90-day breakdown — volume trend,
      SLA performance, priority mix, and depot comparison.
    </p>
  </header>

  <div class="flex-1 min-h-0 p-6">
    <div class="h-full rounded-lg border border-slate-200 bg-white overflow-hidden flex">
      <div class="flex-1 min-w-0">
        <%= render Components::Reports::SaDetailMap.new(height: "100%") %>
      </div>
      <aside id="sa-detail" class="w-96 border-l border-slate-200 overflow-y-auto">
        <div class="p-6 text-sm text-slate-500">
          Click a service area on the map to see details.
        </div>
      </aside>
    </div>
  </div>
</div>

The <aside id="sa-detail"> is the target the Turbo Stream will update. Its initial contents are the placeholder; on click, the stream replaces the inner content with the rendered detail panel.

The structure: a header at top, a flex-1 container below splitting into map (left, fills remaining space) and sidebar (right, fixed 80-character width). overflow-hidden on the parent ensures the sidebar’s scroll doesn’t leak past the container.

Look what just happened

Sign in as the manager. The Reports section now has three items. Click Service Area Detail.

The map fills with the familiar SLA colour scheme — green for high on-time areas, amber for the threshold, red for problem SAs, grey where there’s no data. The sidebar on the right shows a placeholder.

Click any green SA. The sidebar’s contents replace themselves instantly. You see:

  • The SA’s name and SA3 code
  • A big number for completed-job volume, with a small sparkline beside it showing the weekly trend
  • The on-time percentage in colour (green for high, amber for threshold, red for poor), with its own sparkline showing whether performance is stable, climbing, or sliding
  • A four-row breakdown bar showing the priority mix
  • A comparison row: this SA’s volume and SLA next to the depot averages

Click a red SA. The sidebar updates. The numbers tell a different story — fewer jobs, lower on-time percentage, maybe a sparkline showing the slide. The priority breakdown might be heavier on urgent.

Click an SA in a different depot. The depot comparison shifts to that depot’s averages. The reader is browsing the country through the lens of operational performance.

What this introduced

Several patterns worth carrying forward.

on_click: { turbo_stream: ... } for click-driven UI updates. Vera’s declarative bridge from map clicks to in-page state changes via Turbo Streams. The map declares “clicks on this layer dispatch a stream request to this URL”; the server returns stream actions; Turbo applies them.

Turbo Streams as the page-update primitive for click-driven UI. Streams update arbitrary elements on the page by id, unbounded by Frame relationships. The right tool for sidebar updates driven by interactions elsewhere.

Aggregation queries that return chart-shaped data. DATE_TRUNC('week', ...) plus GROUP BY ... ORDER BY produces weekly buckets ready for sparklines. The same shape works for daily, monthly, hourly buckets — just change the truncation.

Sparklines as inline dense data. A small SVG component renders a polyline; data feeds in as an array; colour inherits via currentColor. Reusable for any future inline trend indicator.

Same data, different question. The lesson’s map uses the same /api/service_areas/sla_performance.json endpoint as Lesson 5, but the page asks a different question of it. Reports aren’t only differentiated by what data they show but also by how the user interacts with it. The choropleth-with-popup says “here’s the picture, here’s a glance at one SA’s metric.” The choropleth-with-sidebar says “here’s the picture, click to dive in.”

Where this leaves us

Three reports in the manager’s toolbox. Two interaction patterns established (popups in Lessons 4 and 5; sidebars here). One reusable sparkline component. One new mental model — the map as navigator, not destination.

The next lesson moves to a different visualisation entirely. Heatmaps render dense point data as gradient clouds rather than discrete features — a different mental model for “where is concentration?” Good for cases where individual points aren’t the unit of interest; the gradient is.