Lesson 5 — SLA performance choropleth
The density choropleth from Lesson 4 answered where is work happening? — useful for capacity awareness. This lesson answers a different question: where are we keeping our promises and where are we failing?
That’s the SLA question. Every dispatch operation has service- level agreements — implicit or explicit promises about how quickly work gets done. “Same-day for urgent.” “Within 48 hours for standard.” “Within five business days for routine.” When those promises slip, customers notice; when patterns of slippage cluster geographically, operations notices.
A choropleth is the right visualisation for this. Same polygons, same map, same legend pattern as Lesson 4. But the colour scale is fundamentally different — and that difference is where this lesson’s substance lives.
What changes from Lesson 4
Three things, each with a teaching beat:
- The metric. No longer a count, but a percentage — completed-on-time as a fraction of total completed.
- The colour scale. No longer sequential (light to dark in one hue), but divergent — two hues meeting in the middle, representing “good” and “bad” with a “neutral” zone between.
- The query. More involved — joins, filters, time arithmetic, conditional aggregation. The first time the dispatch deck does real time-based reporting math.
Same component pattern, same gem features. Just a richer expression of what reports can do.
Defining “on time”
Before any code, the operational definition. We’ll say a job is on time if it was completed within a target window of when it was created. That target depends on the priority:
- Urgent — within 4 hours
- High — within 24 hours
- Normal — within 72 hours
- Low — within 7 days
(Real dispatch operations have more nuanced SLAs — business- hours-only counting, weekends excluded, holidays accommodated, priority-by-customer-tier, etc. We’re keeping it simple. The mechanism is what this lesson teaches; the policy is one configuration change away.)
A job is on-time if completed_at - created_at <= target. The
SLA percentage for an SA is the fraction of completed jobs
that hit their target.
We’ll only count completed jobs in the calculation. In-flight work isn’t “late” yet — it just hasn’t finished. SLA performance is fundamentally a retrospective measure.
The SLA module
The policy lives in one place — a Ruby module that owns both the data (the priority-to-target mapping) and a SQL generator for queries that need the policy at row level.
app/services/sla.rb:
|
|
A few things worth understanding about this shape.
TARGETS is the single source of truth. Both target and
sql_target derive from it. Add a new priority — change one
constant, and both Ruby callers and SQL queries pick it up
automatically.
target(priority) is for application code. Anywhere Ruby
asks “what’s the SLA for this job?” — a controller, a
notification job, a job.on_time? predicate — it calls this.
sql_target(column) generates a SQL fragment. The output
is a complete CASE WHEN ... ELSE ... END expression, ready
to drop into a query as if it were a column reference. The
generated SQL looks like:
|
|
Verbose, but Postgres handles CASE expressions efficiently — the planner compiles them to direct branches at query planning time.
The injection guard on sql_target’s argument matters
because the column name is interpolated into the SQL string.
Hardcoded callers like Sla.sql_target('j.priority') are
safe; the regex prevents a future caller from accidentally
passing user input. The interpolated values in the CASE
expression come from TARGETS.keys — hardcoded constants,
always safe.
This pattern — Ruby module owns policy, exposes both application-shaped and SQL-shaped interfaces — is one worth internalising. Beats putting the same logic in a database function, beats inlining a CASE everywhere, beats trying to cram it into ActiveRecord scope chaining. It keeps the policy where the team’s mental model lives (Ruby) while making it available everywhere it’s needed.
The query
The SQL shape, with Sla.sql_target interpolated into it:
|
|
Two things worth understanding about the query shape.
COUNT(*) FILTER (WHERE ...) is Postgres’s filtered
aggregate syntax. It counts rows that match the filter,
ignoring others — without filtering the rest of the result
set. Two filtered aggregates in one query give us “completed
jobs” and “on-time jobs” simultaneously, both grouped by SA.
The alternative is two separate queries plus a manual join,
or a subquery — both messier.
completed_at - created_at in Postgres returns an
interval (e.g. '01:23:45' — one hour, twenty-three minutes,
forty-five seconds). Comparing intervals to other intervals
just works. Postgres time arithmetic is clean.
The 90-day window matters. SLA performance over too short a period (one week) reflects recent operational quirks more than capability; over too long a period (two years) it includes performance from before whatever staffing or process changes shape today’s reality. 90 days is a common operational horizon — recent enough to be relevant, long enough to wash out one-off events.
Run the service object
In console:
|
|
The data shape is the same FeatureCollection as Lesson 4. The
properties differ — three numbers per SA instead of one. SAs
with zero completed jobs in the window have nil for
percentage; we’ll handle that case in the colour expression.
The controller and route
Add to Api::ServiceAreasController:
|
|
Wire the route:
|
|
A new page in the app
Same shape as the choropleth report, different content. New controller action and component.
In ReportsController:
|
|
Route in config/routes.rb:
|
|
Add to the sidebar’s REPORTS constant:
|
|
Page component at app/components/reports/sla_performance.rb:
|
|
The map component — divergent scale
The substantive new thing in this lesson. Create
app/components/sla_performance_map.rb:
|
|
A few things worth understanding here.
The COLOUR_RAMP is a divergent scale. Sequential ramps (Lesson 4) work for “more is more” metrics. Divergent ramps work when the metric has a meaningful midpoint — a value where “above” and “below” mean different things. SLA performance is exactly this. 95% is good; 60% is bad; 80% is mediocre. Each region of the scale conveys different qualitative information, not just “more or less of the same thing.”
The hue shift (red → amber → green) crosses through the visual language of traffic lights and dashboards. Readers don’t need to read the legend to know red is bad and green is good. The colours themselves do half the communicating.
The threshold positions matter. 50% is “failing” not because 50 is special but because anything below it indicates an operation in serious distress. 95% is “excellent” because real operations rarely sustain higher. The breakpoints at 70 and 85 shape the curve to be most informative around the values most SAs actually achieve. If your operation routinely runs at 99%+, you’d compress the ramp to 90-100; if it routinely runs at 60-80, you’d shift downward.
The ["case", ...] expression handles the no-data case.
MapLibre’s ["case", condition1, value1, condition2, value2, ..., else] is its conditional construct — return the first
matching value. Here we test if on_time_pct is a number (it
won’t be if the property is nil/null in the GeoJSON); when
it isn’t, return the no-data colour.
The check ["==", ["typeof", ["get", "on_time_pct"]], "number"]
is verbose but reliable. MapLibre’s ["typeof", ...] returns
the type of an expression result; comparing it to "number"
handles null and undefined correctly.
Order of evaluation — case evaluates conditions in
order, returning the first match. This means the no-data
check must come first. If we put the interpolate call first
and tested for null afterward, the interpolate would error on
null inputs before the case got to its else branch.
The legend
Create app/components/sla_performance_legend.rb:
|
|
Mostly the same shape as Lesson 4’s legend, with two differences worth noting.
The gradient_stops calculation is range-aware. Lesson 4’s
ramp went from 0 to 300; this one goes from 50 to 100. The
position of each stop on the bar is (threshold - min) / range, not threshold / max. A stop at 70% lands at 40% of
the bar ((70 - 50) / 50 = 40%) — without the range adjustment
it would be misplaced.
The no-data note is a small swatch + label below the gradient bar. Communicates that there’s a colour outside the ramp meaning “no data.” A choropleth that uses a no-data colour without explaining it leaves the reader puzzled at grey polygons.
Look what just happened
Sign in as the manager. The Reports section now has two items: Choropleth Report and SLA Performance. Click the new one.
The map fills with colour. Most SAs are some shade of green, amber, or grey. Sydney metro shows a mix — most SAs solidly green (high on-time rate), a few in amber (concerning), maybe one or two in red (problem areas). Outback regions appear grey — no completed jobs in the window means no signal either way.
Hover any green SA. The popup shows something like “On-time: 94.2% (487 of 517).” A red SA: “On-time: 62.1% (38 of 61).” A grey SA: “No completed jobs in the last 90 days.”
The legend in the top-right shows the divergent ramp red → amber → green, with thresholds 50, 70, 85, 95, 100. A separate swatch below explains the grey.
What this introduced
Several things that recur in real reporting:
Single-source-of-truth for policy. Sla::TARGETS is the
canonical SLA policy. target(priority) serves Ruby callers;
sql_target(column) generates SQL fragments. Both consumers
draw from the same constant — no drift between application
behaviour and reporting behaviour.
Filtered aggregates. COUNT(*) FILTER (WHERE ...) lets
one query produce multiple counts over different conditions —
substantially cleaner than subqueries for related metrics.
Divergent colour scales. When a metric has a meaningful midpoint (target, threshold, expected value), the visual communication is much sharper using two-hue scales than single-hue gradients. Pick the scale type that matches your metric’s qualitative shape.
No-data handling. Real datasets have gaps. Letting the absence be a distinct visual category — grey, dashed, hashed — preserves the integrity of the active scale. A “0%” rendered in red would falsely classify “no jobs” as “every job missed” which is operationally wrong.
["case", ...] paint expressions. Conditional logic in
the style language. Useful whenever per-feature paint depends
on more than a simple property lookup.
Where this leaves us
The dispatch deck has its second reporting page, with a more nuanced metric and a more sophisticated visualisation. The choropleth pattern is now established as a tool — same shape, different scales for different stories.
The next lesson takes the maps in a different direction. Instead of adding visualisation layers, we’ll wire the map itself to drive a sidebar — clicking an SA reveals a detail panel with summary information for that area. The map becomes an input device, not just a display.