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 ascope: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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
In app/components/dashboards/dispatcher_dashboard.rb, replace
RegionalMap(height: "100%") with:
|
|
In app/components/dashboards/field_officer_dashboard.rb, replace
LocalMap(height: "100%") with:
|
|
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.rbapp/services/local_jobs.rb,app/services/local_service_area.rb- The old
app/components/regional_map.rbandapp/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.