Skip to content

Lesson 3 — The manager’s overview

The dashboard route exists. Right now it shows a placeholder. This lesson lands the manager’s version: the national overview of the entire dispatch operation. By the end, the manager signs in to a page showing the operation’s scale, a map covering all 9 depots and 340 service areas, and a side panel with recent activity and busiest regions.

Three new techniques appear together:

  • Service objects for view data. A service object produces a hash of everything the dashboard needs. The controller stays thin.
  • Phlex page components. Up to now we’ve used Phlex for small reusable units (PageHeader, ServiceAreaMap). Pages themselves have been ERB. The dashboard is the right place to introduce Phlex pages because role-specific variation is hard to express cleanly in ERB.
  • Phlex Kits. A Phlex feature that lets components be called as methods (PageHeader(...)) instead of with render Components::PageHeader.new(...). Composition gets much cleaner.
Phlex pages vs ERB pages. This is the first page in the dispatch deck built fully in Phlex. Other pages — the Lab page, the soon-to-be Jobs index — remain ERB composing Phlex components. Both styles are valid; the dashboard’s role-specific variation makes Phlex pages the better fit here. There’s no need to convert existing ERB pages.

Step 1 — Phlex Kits in the chassis

To call components as methods (PageHeader(...) instead of render Components::PageHeader.new(...)), Phlex Kits need enabling.

A Phlex Kit is a module that, when extended with Phlex::Kit, makes the constants under it callable as methods inside any class that includes the module. The setup is two steps: define a namespace module, extend it with Phlex::Kit, then include that module in the base component class.

Open app/components/base.rb. It currently looks something like:

1
2
3
class Components::Base < Phlex::HTML
  # ... existing chassis content
end

Add the module definition and include directive at the top:

1
2
3
4
5
6
7
8
9
module Components
  extend Phlex::Kit
end

class Components::Base < Phlex::HTML
  include Components

  # ... existing chassis content unchanged
end

The module Components; extend Phlex::Kit; end block defines the namespace and turns it into a kit. The include Components line on Components::Base is what makes every component callable as a method inside any subclass.

Why not extend Phlex::Kit directly on Components::Base? Phlex::Kit is designed to be extended on modules, not classes. Extending it on a class produces a runtime error. The pattern above — kit on the namespace module, included by the base class — is the supported shape.

After saving, restart the Rails server. The kit machinery loads at boot, and components become callable as methods.

Step 2 — Define the Dashboards namespace

The dashboards we’re about to build live at app/components/dashboards/. Zeitwerk will autoload classes under this directory as Components::Dashboards::* — nesting the namespace based on the directory structure under app/components/.

For Zeitwerk to recognise Components::Dashboards as a valid module, it needs an explicit definition. Create app/components/dashboards.rb:

1
2
module Components::Dashboards
end

A two-line file that just defines the empty module. Zeitwerk sees this and now considers Components::Dashboards a known constant. Without it, references to Components::Dashboards::ManagerDashboard would fail with NameError: uninitialized constant.

This is a small Zeitwerk convention — every namespace sub-directory under autoload paths needs a module file at the parent level. As we add more grouped components in later modules (forms, panels, dialogs), we’ll create similar single-line module files.

Step 3 — The service object

The dashboard needs three sets of data: top-level counts, recent activity, and busiest regions. Putting the queries in the controller fattens it up unnecessarily; a service object handles it cleanly.

Create app/services/manager_dashboard_data.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
module ManagerDashboardData
  extend self

  def call(user)
    {
      counts:          counts,
      recent_jobs:     recent_jobs,
      busiest_regions: busiest_regions
    }
  end

  private

  def counts
    {
      depots:         Depot.count,
      service_areas:  ServiceArea.count,
      field_officers: User.where(role: "field_officer").count,
      active_jobs:    Job.where.not(status: %w[complete cancelled]).count
    }
  end

  def recent_jobs
    Job.where(status: %w[complete cancelled])
       .where.not(completed_at: nil)
       .order(completed_at: :desc)
       .limit(5)
       .includes(:customer, :service_area, :assigned_to)
  end

  def busiest_regions
    ServiceArea
      .joins(:jobs)
      .where("jobs.created_at >= ?", 7.days.ago)
      .group("service_areas.id", "service_areas.name")
      .select("service_areas.name AS name, COUNT(jobs.id) AS job_count")
      .order("job_count DESC")
      .limit(5)
  end
end

The pattern is the same module + extend self shape as the SerializeServiceAreas service from Module 4 Lesson 1. Public call returns the data hash; private methods do the work. Each query lives in its own method, named for what it returns.

A few details worth noting.

recent_jobs includes both complete and cancelled statuses. The dashboard shows actual operational outcomes — jobs that finished one way or another. Pending and in-progress jobs aren’t “recent activity” in this sense; they haven’t completed their lifecycle yet.

recent_jobs uses .includes(:customer, :service_area, :assigned_to) to avoid N+1 queries. Each entry needs the customer name, the service area name, and the assigned field officer’s name — without .includes, rendering 5 jobs would trigger 15 extra queries.

busiest_regions is scoped to the last 7 days. A dashboard’s “busiest” should reflect current operational reality, not the all-time backlog. Seven days smooths out day-to-day noise while staying recent enough to act on. We use created_at rather than completed_at so the count includes pending and in-progress jobs — work that’s being handled in those regions, not just completed work.

Step 4 — The small components

Three new components for the dashboard. Each is small, single-purpose, and lives flat in app/components/ (no sub-directory, so the namespace stays simple — just Components::*).

app/components/stat_tile.rb — a single metric tile in the stat strip:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Components::StatTile < Components::Base
  prop :label,    String
  prop :value,    _Any
  prop :sublabel, String

  def view_template
    div(class: "flex flex-col") do
      div(class: "text-3xl font-semibold text-text tabular-nums") { @value.to_s }
      div(class: "text-sm text-text mt-0.5") { @label }
      div(class: "text-xs text-text-muted") { @sublabel }
    end
  end
end

app/components/recent_activity_list.rb — the recent activity section of the side panel. Each entry shows the job’s status, the customer with their suburb for context, and the field officer who handled it.

 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
class Components::RecentActivityList < Components::Base
  prop :jobs, _Any

  STATUS_STYLES = {
    "complete"  => { label: "Complete",  classes: "text-emerald-700 bg-emerald-50" },
    "cancelled" => { label: "Cancelled", classes: "text-rose-700 bg-rose-50" }
  }.freeze

  def view_template
    section do
      h2(class: "text-sm font-semibold text-text uppercase tracking-wider mb-3") { "Recent activity" }
      ul(class: "space-y-3") do
        @jobs.each do |job|
          render_item(job)
        end
      end
    end
  end

  private

  def render_item(job)
    li(class: "flex items-start justify-between gap-3 pb-3 border-b border-border last:border-0 last:pb-0") do
      div(class: "min-w-0 flex-1") do
        render_header_row(job)
        div(class: "text-sm text-text mt-1 truncate") do
          plain "#{job.customer.name} · #{job.customer.suburb}"
        end
        div(class: "text-xs text-text-muted truncate") do
          plain "FO: #{job.assigned_to&.name || '—'}"
        end
      end
      div(class: "text-xs text-text-muted shrink-0 tabular-nums") do
        relative_time(job.completed_at)
      end
    end
  end

  def render_header_row(job)
    style = STATUS_STYLES[job.status] || { label: job.status.titleize, classes: "text-text-muted bg-surface-alt" }
    span(class: "inline-block px-2 py-0.5 rounded text-xs font-medium #{style[:classes]}") do
      style[:label]
    end
  end

  def relative_time(time)
    return "" unless time
    seconds = Time.current - time
    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                      time.strftime("%-d %b")
    end
  end
end

Three rows of information per item: status pill at the top (green for complete, rose for cancelled), customer name and suburb in the middle, the field officer’s name on the bottom prefixed FO:. The FO: prefix matters because field officer names are easy to mistake for customer names — prefixing makes it unambiguous at a glance.

The assigned_to&.name || '—' handles the rare case where a cancelled job was never assigned to a field officer. Defensive because it’s cheap; rendering “FO: —” is clearer than blank or nil-error.

app/components/busiest_regions_list.rb — the busiest regions section. The heading reflects the data’s actual time window so the manager isn’t left guessing whether they’re looking at all-time totals or recent activity.

 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
class Components::BusiestRegionsList < Components::Base
  prop :regions, _Any

  def view_template
    section do
      div(class: "flex items-baseline justify-between mb-3") do
        h2(class: "text-sm font-semibold text-text uppercase tracking-wider") { "Busiest regions" }
        span(class: "text-xs text-text-muted") { "last 7 days" }
      end
      ol(class: "space-y-2") do
        @regions.each_with_index do |region, idx|
          render_row(region, idx)
        end
      end
    end
  end

  private

  def render_row(region, idx)
    li(class: "flex items-center gap-3") do
      div(class: "text-sm text-text-muted tabular-nums w-5") { (idx + 1).to_s }
      div(class: "flex-1 min-w-0") do
        div(class: "text-sm text-text truncate") { region.name }
      end
      div(class: "text-sm text-text-muted tabular-nums") { region.job_count.to_s }
    end
  end
end

Each takes simple props and produces a small, self-contained piece of UI. They can be composed in any order; the dashboard determines the order.

Step 5 — The dashboard page component

Create app/components/dashboards/manager_dashboard.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
class Components::Dashboards::ManagerDashboard < Components::Base
  prop :data, Hash

  def view_template
    div(class: "h-full flex flex-col bg-bg") do
      PageHeader(
        title:      "Operations dashboard",
        subtitle:   "National overview of all dispatch activity",
        breadcrumb: ["Dashboard"]
      )

      render_stat_strip

      div(class: "flex-1 min-h-0") do
        MapPanelLayout do |l|
          l.map   { ServiceAreaMap() }
          l.panel { render_side_panel }
        end
      end
    end
  end

  private

  def render_stat_strip
    div(class: "grid grid-cols-2 md:grid-cols-4 gap-4 px-8 py-5 bg-surface border-b border-border") do
      StatTile(label: "Depots",         value: @data[:counts][:depots],         sublabel: "across the country")
      StatTile(label: "Service areas",  value: @data[:counts][:service_areas],  sublabel: "ABS SA3 boundaries")
      StatTile(label: "Field officers", value: @data[:counts][:field_officers], sublabel: "deployed nationally")
      StatTile(label: "Active jobs",    value: @data[:counts][:active_jobs],    sublabel: "currently in flight")
    end
  end

  def render_side_panel
    div(class: "p-6 space-y-8") do
      RecentActivityList(jobs:    @data[:recent_jobs])
      BusiestRegionsList(regions: @data[:busiest_regions])
    end
  end
end

A few things worth pausing on.

The class is Components::Dashboards::ManagerDashboard. The full path matches the file location: app/components/dashboards/manager_dashboard.rb. Zeitwerk infers the nested namespace from the directory structure.

The Kit syntax in action. PageHeader(...), StatTile(...), MapPanelLayout(...), ServiceAreaMap(), RecentActivityList(...), BusiestRegionsList(...) — all calls without .new or render. This is the kit machinery from Step 1 paying off.

The data flows in through props. The dashboard receives @data from the controller (which got it from ManagerDashboardData.call). The page is purely about rendering — no queries, no logic about what to show.

Methods like render_stat_strip and render_side_panel break up the view template into named regions. Same pattern as helper methods in any view.

Step 6 — The controller

Replace app/controllers/dashboard_controller.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class DashboardController < ApplicationController
  def index
    @data = data_for(Current.user)
    @page = page_component_for(Current.user.role)
  end

  private

  def data_for(user)
    case user.role
    when "manager" then ManagerDashboardData.call(user)
    # Lesson 4 will add dispatcher and field officer cases
    end
  end

  def page_component_for(role)
    case role
    when "manager" then Components::Dashboards::ManagerDashboard
    # Lesson 4 will add dispatcher and field officer cases
    end
  end
end

And the view at app/views/dashboard/index.html.erb:

1
<%= render @page.new(data: @data) %>

One line. The page component takes over from there.

Activity 1 — Visit the manager’s dashboard

Restart the Rails server (the extend Phlex::Kit change in base.rb and the new module file at app/components/dashboards.rb both load at boot, not on reload).

Sign in as the manager (manager@vera.test / password). Click Dashboard in the sidebar (or visit /dashboard).

You should see:

  • The page header with “Operations dashboard” and breadcrumb
  • The stat strip showing 9 depots, 340 service areas, 30 field officers, and your active job count
  • The map filling the left, showing all of Australia with 9 depot pins and 340 service area outlines
  • The side panel on the right with two sections — Recent activity (5 completed jobs) and Busiest regions (5 SA3s)

The map is your Components::ServiceAreaMap from Module 4 — no changes needed; it already shows the manager’s national view. Hover over an SA3 polygon and it highlights, just as in the Lab page. Click a depot pin and the popup appears. All of Module 4’s work composes into the dashboard unchanged.

Activity 2 — Sign in as a different role

Sign out and sign back in as dispatcher@vera.test. Visit /dashboard. You’ll see… a routing error or a nil rendering.

The DashboardController’s case statements don’t yet handle the dispatcher or field officer roles. Lesson 4 builds those out. For now, the manager’s dashboard works; the others follow the same pattern.

Where this leaves us

The manager has a working dashboard. The architectural patterns are all in place:

  • Service objects for view data (the controller stays thin)
  • Phlex page components for pages with significant internal structure
  • Phlex Kits for terse, readable composition
  • The namespace pattern for component groupings (app/components/<group>.rb defines the namespace; classes inside live in <group>/)

Lesson 4 reuses these patterns to build the dispatcher’s regional dashboard and the field officer’s personal dashboard. Each will have a service object, a page component, and a controller case clause — same shape as the manager’s, different content.