Skip to content

Lesson 8 — Consolidating the operations layer

Stand back from what we’ve built across Module 5 and look at it honestly. Three role-scoped dashboards. Three sets of GeoJSON services — RegionalServiceAreas, RegionalJobs, RegionalFieldOfficers, LocalServiceArea, LocalJobs, and so on. Three map components. Three sets of routes.

The shapes repeat. Every service has the same feature-encoding logic; only the WHERE clause differs. Every map component has the same add_service_areas, add_jobs, add_field_officers methods; only the URLs differ. The duplication isn’t an oversight — it accumulated lesson by lesson, each scope built when its role’s dashboard needed it. But seen together, the right shape becomes clear.

This lesson collapses the duplication. Three service objects per feature type become one with three methods. Three map components become one with a scope: prop. Controllers move into an Api:: namespace and URLs gain an /api/ prefix. Along the way, the manager’s national map — which has only ever shown SA boundaries — gets the same job and FO layers the other roles see.

No new GIS, no new Vera features. Just architectural cleanup that pays off across every role’s view.

The shape we’re going to

Three pieces:

  • One service module per feature type, each with three methods (national, regional(user), local(user)). The serialisation logic lives once; each method’s body is just the WHERE clause.
  • One map component, OperationsMap, with a scope: prop. The layer configuration is shared; only URLs and a few visual tunings vary by scope.
  • An Api:: namespace for the controllers serving GeoJSON. The URLs become /api/jobs/national.json, /api/jobs/regional.json, etc. — declaring that these endpoints serve data, not user-facing pages.

The dashboards’ page components don’t change in any meaningful way. Each one just renders OperationsMap with the appropriate scope.

The service modules

Replace app/services/regional_jobs.rb, app/services/local_jobs.rb, and any national variant you’ve built with a single app/services/jobs.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
module Jobs
  extend self

  ACTIVE_STATUSES = %w[pending scheduled in_progress].freeze

  # All active jobs across the country.
  def national
    serialise(base_relation)
  end

  # Active jobs across the user's depot region.
  def regional(user)
    serialise(
      base_relation.where(service_area_id: user.depot.service_areas.select(:id))
    )
  end

  # Active jobs in the user's SA, scheduled for today or unscheduled.
  def local(user)
    serialise(
      base_relation
        .where(service_area_id: user.service_area_id)
        .where("scheduled_for IS NULL OR scheduled_for::date = ?", Date.current)
    )
  end

  private

  def base_relation
    Job.where(status: ACTIVE_STATUSES).includes(:customer, :assigned_to)
  end

  def serialise(relation)
    {
      type: "FeatureCollection",
      features: relation.map { |job| feature_for(job) }
    }
  end

  def feature_for(job)
    {
      type:       "Feature",
      id:         job.id,
      geometry:   RGeo::GeoJSON.encode(job.location),
      properties: {
        status:       job.status,
        status_label: job.status.tr("_", " ").capitalize,
        customer:     job.customer.name,
        address:      job.customer.address,
        suburb:       job.customer.suburb,
        postcode:     job.customer.postcode,
        phone:        job.customer.phone,
        description:  job.description.presence || "Service call",
        fo_name:      job.assigned_to&.name,
        waiting:      waiting_label(job.created_at),
        scheduled:    job.scheduled_for&.strftime("%-l:%M%P")
      }
    }
  end

  def waiting_label(created_at)
    seconds = Time.current - created_at
    case seconds
    when 0..3600         then "#{(seconds / 60).to_i}m ago"
    when 3601..86_400    then "#{(seconds / 3600).to_i}h ago"
    when 86_401..604_800 then "#{(seconds / 86_400).to_i}d ago"
    else                      created_at.strftime("%-d %b")
    end
  end
end

Three things happened here.

The serialisation logic lives once. feature_for and waiting_label are private; each public method calls serialise(relation) and that’s it. The 30 lines of GeoJSON shape that were copied across three files now exist in exactly one place.

Each scope’s WHERE clause stays explicit. national returns all active jobs. regional adds depot-scoping. local adds SA-scoping plus today’s-or-unscheduled filtering. Reading the three methods side by side you can see exactly how each role’s view differs from the others.

The base relation is shared. Job.where(status: ACTIVE_STATUSES).includes(:customer, :assigned_to) is the common foundation. Each scope refines it further. If the active- status definition changes, it changes once.

The same pattern applies to app/services/service_areas.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
module ServiceAreas
  extend self

  def national
    serialise(ServiceArea.all)
  end

  def regional(user)
    serialise(user.depot.service_areas)
  end

  # The user's single SA, returned as a one-feature collection so
  # the wire format matches the other scopes.
  def local(user)
    serialise(ServiceArea.where(id: user.service_area_id))
  end

  private

  def serialise(relation)
    {
      type: "FeatureCollection",
      features: relation.map { |sa| feature_for(sa) }
    }
  end

  def feature_for(sa)
    {
      type:       "Feature",
      id:         sa.id,
      geometry:   RGeo::GeoJSON.encode(sa.boundary),
      properties: { name: sa.name, code: sa.code }
    }
  end
end

And app/services/field_officers.rb. With one wrinkle — some SA boundaries have minor self-intersections that RGeo’s centroid calculation rejects. PostGIS handles them gracefully, so we compute centroids server-side via SQL:

 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
module FieldOfficers
  extend self

  STATES = %w[available en_route at_job].freeze
  POSITION_JITTER_DEGREES = 0.02

  def national
    serialise(<<~SQL)
      SELECT u.id,
             u.name,
             ST_X(ST_Centroid(sa.boundary)) AS lng,
             ST_Y(ST_Centroid(sa.boundary)) AS lat
      FROM users u
      JOIN service_areas sa ON sa.id = u.service_area_id
      WHERE u.role = 'field_officer'
    SQL
  end

  def regional(user)
    serialise(<<~SQL)
      SELECT u.id,
             u.name,
             ST_X(ST_Centroid(sa.boundary)) AS lng,
             ST_Y(ST_Centroid(sa.boundary)) AS lat
      FROM users u
      JOIN service_areas sa ON sa.id = u.service_area_id
      WHERE u.role = 'field_officer'
        AND u.depot_id = #{user.depot_id.to_i}
    SQL
  end

  private

  def serialise(sql)
    rows = User.connection.select_all(sql).rows

    {
      type: "FeatureCollection",
      features: rows.map { |id, name, lng, lat|
        jittered_lng = lng + (rand - 0.5) * 2 * POSITION_JITTER_DEGREES
        jittered_lat = lat + (rand - 0.5) * 2 * POSITION_JITTER_DEGREES
        state = STATES.sample
        {
          type:       "Feature",
          id:         id,
          geometry:   { type: "Point", coordinates: [jittered_lng, jittered_lat] },
          properties: {
            name:        name,
            state:       state,
            state_label: state.tr("_", " ").capitalize
          }
        }
      }
    }
  end
end

There’s no local method here — a field officer is the user, so rendering one truck on a map of one SA isn’t useful. Their map still shows the SA boundary and their jobs, but no FO layer.

Computing centroids in PostGIS is a real performance win at national scope. For 3,000 field officers, doing one ST_Centroid call per FO in Ruby would mean 3,000 small geometry calculations and the per-call overhead that goes with them. The SQL approach computes all 3,000 centroids in one query, returns plain numeric coordinates, and lets the database handle invalid geometries gracefully. At regional scope (40-200 FOs typically) the difference is small; at national scope it’s substantial.

The #{user.depot_id.to_i} interpolation is safe because of the explicit integer cast. If your shop’s style prefers parameterised queries via sanitize_sql_array, the alternative is fine — but the .to_i guards against injection in this case.

The API namespace

Move the controllers under app/controllers/api/. Each one shrinks to a handful of action lines:

app/controllers/api/jobs_controller.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Api::JobsController < ApplicationController
  def national
    render json: Jobs.national
  end

  def regional
    render json: Jobs.regional(Current.user)
  end

  def local
    render json: Jobs.local(Current.user)
  end
end

app/controllers/api/service_areas_controller.rb and app/controllers/api/field_officers_controller.rb follow the same shape. Each is essentially a switchboard: receive the request, call the right service method, render the result.

The Api:: namespace declares that these endpoints serve JSON to client-side consumers — MapLibre on the dashboards, future AJAX, external integrations. They’re not user-facing controllers. The namespace also keeps the routes file readable as it grows.

In config/routes.rb, replace the scattered get "/jobs/regional" and similar lines with:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace :api do
  resources :jobs, only: [] do
    collection do
      get :national
      get :regional
      get :local
    end
  end

  resources :service_areas, only: [] do
    collection do
      get :national
      get :regional
      get :local
    end
  end

  resources :field_officers, only: [] do
    collection do
      get :national
      get :regional
    end
  end
end

URLs become /api/jobs/national.json, /api/jobs/regional.json, and so on. A clean hierarchy that reads like a scope.

The unified map component

Replace RegionalMap, LocalMap, and (if you built it separately) the manager’s national map with a single Components::OperationsMap:

  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
class Components::OperationsMap < Components::Base
  prop :scope,  Symbol  # :national, :regional, or :local
  prop :id,     String, default: -> { "operations-map" }
  prop :height, String, default: -> { "100%" }

  JOB_STATUS_COLOURS = {
    "pending"     => "#f59e0b",
    "scheduled"   => "#3b82f6",
    "in_progress" => "#10b981"
  }.freeze

  FO_STATE_COLOURS = {
    "available" => "#f59e0b",
    "en_route"  => "#3b82f6",
    "at_job"    => "#10b981"
  }.freeze

  JOB_POPUP_TEMPLATE = <<~HTML.freeze
    # ... same as RegionalMap had ...
  HTML

  FO_POPUP_TEMPLATE = <<~HTML.freeze
    # ... same as RegionalMap had ...
  HTML

  def view_template
    render Vera::Map.new(id: @id, height: @height, style: :voyager) do |m|
      add_service_areas(m)
      add_jobs(m)
      add_field_officers(m) unless @scope == :local
    end
  end

  private

  def add_service_areas(m)
    m.source :service_areas, url: "/api/service_areas/#{@scope}.json",
                             fit_bounds: service_area_fit_bounds

    m.layer :service_areas_fill, source: :service_areas, type: :fill,
            paint:    { fill_color: "#1e3a5f", fill_opacity: 0.05 },
            on_hover: { fill_opacity: 0.15 }

    m.layer :service_areas_outline, source: :service_areas, type: :line,
            paint:    service_area_outline_paint,
            on_hover: service_area_outline_on_hover
  end

  def add_jobs(m)
    m.source :jobs, url: "/api/jobs/#{@scope}.json"

    m.layer :jobs_circle, source: :jobs, type: :circle,
            paint: {
              circle_color: ["match", ["get", "status"],
                             "pending",     JOB_STATUS_COLOURS["pending"],
                             "scheduled",   JOB_STATUS_COLOURS["scheduled"],
                             "in_progress", JOB_STATUS_COLOURS["in_progress"],
                             "#64748b"],
              circle_radius:       jobs_radius,
              circle_stroke_color: "#ffffff",
              circle_stroke_width: 1
            },
            on_hover: { circle_radius: jobs_radius + 2 },
            popup:    { template: JOB_POPUP_TEMPLATE }
  end

  def add_field_officers(m)
    m.source :field_officers, url: "/api/field_officers/#{@scope}.json"

    m.image :truck, svg: PhlexIcons::Hero::Truck.new(class: "size-8"),
                    tintable: true

    m.layer :field_officers, source: :field_officers, type: :symbol,
            layout: {
              icon_image:         "truck",
              icon_size:          fo_icon_size,
              icon_anchor:        "center",
              icon_allow_overlap: true
            },
            paint: {
              icon_color: ["match", ["get", "state"],
                           "available", FO_STATE_COLOURS["available"],
                           "en_route",  FO_STATE_COLOURS["en_route"],
                           "at_job",    FO_STATE_COLOURS["at_job"],
                           "#64748b"]
            },
            popup: { template: FO_POPUP_TEMPLATE }
  end

  # Per-scope visual tuning.

  def jobs_radius
    case @scope
    when :national then 4
    when :regional then 5
    when :local    then 6
    end
  end

  def fo_icon_size
    case @scope
    when :national then 0.8
    when :regional then 1.0
    end
  end

  def service_area_fit_bounds
    case @scope
    when :local then { padding: 40 }
    else             { padding: 20 }
    end
  end

  def service_area_outline_paint
    case @scope
    when :local
      { line_color: "#94a3b8", line_width: 1, line_opacity: 0.5 }
    else
      { line_color: "#dc2626", line_width: 1, line_opacity: 0.3 }
    end
  end

  def service_area_outline_on_hover
    case @scope
    when :local then { line_width: 1, line_opacity: 0.5 }
    else             { line_width: 2, line_opacity: 1.0 }
    end
  end
end

A few things worth noting in the structure.

The public surface is small. One prop (scope) plus the standard id and height. The component knows what to render based on the scope; the calling page just declares which view it wants.

Per-scope tuning lives in private helper methods. jobs_radius, fo_icon_size, and the outline-styling methods each have a small case statement. The variation between scopes is real — at national zoom, smaller circles overlap less; at local zoom, larger circles read better — but it’s localised, not scattered through the layer declarations.

The local scope skips the FO layer. add_field_officers(m) unless @scope == :local. The field officer is the user; rendering their own truck on their own map of their own SA is meaningless.

The service area outline changes character at local scope. National and regional use red because there are many polygons to distinguish — the boundary needs to fight for attention. Local has one polygon and no neighbours showing; a quieter slate boundary keeps the viewport oriented without dominating.

Updating the dashboard pages

Each dashboard page is a one-line change. In app/components/dashboards/manager_dashboard.rb, find wherever the existing manager map is rendered and replace with:

1
OperationsMap(scope: :national, height: "100%")

In app/components/dashboards/dispatcher_dashboard.rb, replace RegionalMap(height: "100%") with:

1
OperationsMap(scope: :regional, height: "100%")

In app/components/dashboards/field_officer_dashboard.rb, replace LocalMap(height: "100%") with:

1
OperationsMap(scope: :local, height: "100%")

That’s it. Three lines. The pages don’t need to know anything else about the maps — they just declare which scope.

Cleaning up the old files

Once the new code is in place and verified, delete:

  • app/services/regional_jobs.rb, app/services/regional_service_areas.rb, app/services/regional_field_officers.rb
  • app/services/local_jobs.rb, app/services/local_service_area.rb
  • The old app/components/regional_map.rb and app/components/local_map.rb
  • The old non-namespaced JobsController, ServiceAreasController, FieldOfficersController (if they only existed for these GeoJSON endpoints)
  • The old non-namespaced routes (get "/jobs/regional", ..., etc.)
  • The original Module 4 get "/service_areas", to: "service_areas#index" route (its replacement is /api/service_areas/national.json)

Verify by signing in as each role and checking the network tab — all map sources should be hitting /api/.../... URLs.

What just happened to the manager’s map

The manager’s national dashboard previously showed only SA boundaries — 340 red polygons, no jobs, no field officers. With the consolidation, OperationsMap(scope: :national) renders the same three layers it always rendered, just at national scope.

Sign in as the manager and refresh. The map now shows:

  • 340 SA polygons (as before)
  • ~14,000 active job circles, scattered across the country
  • ~3,000 field officer trucks, also scattered

This is going to look chaotic. Sydney metro is a clouded mass of overlapping circles and trucks. Melbourne similar. Brisbane similar. The popups still work for individual features but visually the map has stopped communicating at this scale.

That’s not a bug — it’s the next teaching beat. Module 6 introduces clustering specifically to address what we’re seeing here. Same code, same data, very different rendering technique.

What this refactor earned us

A few things:

Less code to maintain. Three service objects per feature type collapsed into one each. Three map components became one. Adding a fourth scope (a “depot manager region”, say) is now adding one method to each service module and one branch to the map’s case statements — not creating three new files.

The architecture is honest about what changes. Each scope’s WHERE clause is one method body. Each scope’s visual tuning is one branch of one case statement. The variation is small and localised; the shared work is exactly the work that should be shared.

The API surface is declared. The /api/ prefix is a signpost: these are JSON endpoints serving spatial data. As the app grows, future endpoints have an obvious home, and there’s no ambiguity about what a /jobs URL might mean.

The manager’s map is no longer a special case. It uses the same component, the same services, the same patterns as the other two. Whatever new layer or visualisation Module 6 adds, all three roles benefit from the same change.

Where this leaves us

Module 5 closes with the dispatch deck running on a clean, scope-parameterised foundation. Three dashboards, one OperationsMap, three service modules, an Api:: namespace, and the manager’s map showing the full operational picture — at least for now, before clustering tames it.

The next module turns up the data volume substantially and introduces the visualisation techniques the new scale demands. The architecture won’t change again. From here on, the work is about extracting insight from the data we have, not restructuring how we get it.