Skip to content

Lesson 6 — The dispatcher’s dashboard

Lessons 4 and 5 built the dispatcher’s map: scoped to one depot’s region, layered with SAs, jobs, and FOs, organised into clean composition methods. The map answers what’s happening in my region right now?

A real dispatch deck doesn’t show only a map, though. The dispatcher needs more than spatial awareness — they need to know how many jobs are pending, what’s scheduled today, who’s going where. The map shows shape; the panels around it carry the numbers and the lists that drive action.

This lesson wraps the map in the rest of the dashboard. Two parts:

  • A stats strip above the map — four tiles narrating the day’s job lifecycle from pending through to complete.
  • A sidebar to the right of the map — pending jobs at the top (the action queue), today’s schedule below (what’s booked).

No new map code. The work is on the data layer (one service object handles all the panels) and the layout (composition).

Part 1 — The stats panel

Above the map, a strip of four tiles narrating the day’s job lifecycle: pending, scheduled today, in progress, complete today. The four counts read left to right as a job’s day.

The data service. Lesson 4 sketched DispatcherDashboardData as an empty hash. Now it earns its keep. Update app/services/dispatcher_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
module DispatcherDashboardData
  extend self

  def call(user)
    sa_ids = user.depot.service_areas.select(:id)
    {
      depot_name: user.depot.name,
      counts: counts(sa_ids),
      pending: {},
      todays_schedule: {}
    }
  end

  private

  def counts(sa_ids)
    today = Date.current
    {
      pending:         Job.where(service_area_id: sa_ids, status: "pending").count,
      scheduled_today: Job.where(service_area_id: sa_ids, status: "scheduled",
                                 scheduled_for: today.all_day).count,
      in_progress:     Job.where(service_area_id: sa_ids, status: "in_progress").count,
      complete_today:  Job.where(service_area_id: sa_ids, status: "complete",
                                 completed_at: today.all_day).count
    }
  end
end

The four counts are scoped to the dispatcher’s region by the same service_area_id IN (...) shape we used for the jobs on the map. “Today” filters use Date.current.all_day, which expands to a range covering midnight-to-midnight in the application timezone — exactly what “scheduled today” or “complete today” should mean.

Each Job.where(...).count is a single SQL COUNT(*) query. Four queries per dashboard load is fine; we’re not yet at the scale where this would matter. Module 7 introduces aggregation patterns that compute many counts in one query, useful when the panel grows or the regions grow.

Wire the service into the controller. Update 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)
    when "dispatcher" then DispatcherDashboardData.call(user)
    end
  end

  def page_component_for(role)
    case role
    when "manager"    then Components::Dashboards::ManagerDashboard
    when "dispatcher" then Components::Dashboards::DispatcherDashboard
    end
  end
end

Update the view to pass the data. In app/views/dashboard/index.html.erb:

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

Update the page component to take data: as a prop and render a stat strip above the map. In app/components/dashboards/dispatcher_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
class Components::Dashboards::DispatcherDashboard < Components::Base
  prop :data, Hash
  prop :user, User

  def view_template
    div(class: "h-full flex flex-col bg-slate-50") do
      PageHeader(
        title:      "#{@user.depot.name} dispatch",
        subtitle:   "Today's operation in your region",
        breadcrumb: ["Dashboard"]
      )

      render_stat_strip

      div(class: "flex-1 min-h-0 p-6") do
        div(class: "h-full rounded-lg border border-slate-200 bg-white overflow-hidden") do
          RegionalMap(height: "100%")
        end
      end
    end
  end

  private

  def render_stat_strip
    div(class: "grid grid-cols-2 md:grid-cols-4 gap-4 px-6 py-5 bg-slate-50 border-b border-slate-200") do
      StatTile(label: "Pending",         value: @data[:counts][:pending],         sublabel: "awaiting assignment")
      StatTile(label: "Scheduled today", value: @data[:counts][:scheduled_today], sublabel: "booked for today")
      StatTile(label: "In progress",     value: @data[:counts][:in_progress],     sublabel: "currently running")
      StatTile(label: "Complete today",  value: @data[:counts][:complete_today],  sublabel: "finished today")
    end
  end
end

StatTile is the same component the manager dashboard uses in Module 4 — same visual language, same prop shape. The dispatcher’s stats panel reuses Module 4’s tile rather than inventing a new one; visual consistency across roles is part of what makes the dashboard family feel like one product.

Look what we just did. Refresh the dashboard. Above the map, a strip of four tiles. The day’s lifecycle reads left to right: how many jobs need assigning, how many are booked, how many are running, how many are done.

Part 2 — The pending jobs sidebar section

The first sidebar section: pending jobs ordered by age. The oldest sits at the top — that’s what’s been waiting longest, which is what the dispatcher should look at first.

Extend the data service with a pending section:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def call(user)
  sa_ids = user.depot.service_areas.select(:id)

  {
    depot_name: user.depot.name,
    counts:  counts(sa_ids),
    pending: pending_section(sa_ids)
  }
end

private

# ... counts method unchanged ...

def pending_section(sa_ids)
  relation = Job.where(service_area_id: sa_ids, status: "pending")
  {
    total: relation.count,
    items: relation.order(created_at: :asc)
                   .limit(6)
                   .includes(:customer)
  }
end

The pending section returns both a count and the six oldest entries. The count drives the section header (“43 total — showing 6”); the six entries render the list itself. The dispatcher gets the truth about how big the queue is, even when only a slice fits on screen.

The total: count is a separate SQL query from the items: fetch — both running through the same WHERE clause. We could fetch the items and call .size on the array, but that conflates “what’s the total” with “give me the first six,” and the count would silently start meaning “either 6 or fewer.” Two queries makes the two questions explicit.

The component. Create app/components/pending_jobs_list.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
class Components::PendingJobsList < Components::Base
  prop :total, Integer
  prop :items, _Any

  def view_template
    section do
      div(class: "flex items-baseline justify-between mb-3") do
        h2(class: "text-sm font-semibold text-slate-700 uppercase tracking-wider") { "Pending jobs" }
        span(class: "text-xs text-slate-500") { header_count }
      end

      if @items.any?
        ul(class: "space-y-3") do
          @items.each { |job| render_item(job) }
        end
      else
        p(class: "text-sm text-slate-500 italic") { "No pending jobs." }
      end
    end
  end

  private

  def header_count
    if @total <= 5
      "#{@total} total"
    else
      "#{@total} total — showing top 5"
    end
  end

   def render_item(job)
    li(class: "flex items-start justify-between gap-3 pb-3 border-b border-slate-200 last:border-0 last:pb-0") do
      div(class: "min-w-0 flex-1") do
        div(class: "text-sm text-slate-900 font-medium truncate") do
          plain "#{job.customer.name} · #{job.customer.suburb}"
        end
        div(class: "text-sm text-slate-500 font-medium italic truncate") do
          plain "#{job.description}"
        end
        div(class: "text-sm text-slate-500 font-medium truncate") do
          plain "ph: #{job.customer.phone}"
        end
      end
      div(class: "shrink-0 flex flex-col items-end gap-1") do
        div(class: "text-xs text-slate-500 tabular-nums") { waiting(job.created_at) }
        span(class: "inline-block px-2 py-0.5 rounded text-xs font-medium text-amber-700 bg-amber-50") { "Pending" }
      end
    end
  end

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

Oldest first — the longest-waiting job sits at the top, since that’s what needs action soonest. The amber pill matches the map’s pending colour, so the side panel and the map speak the same visual language: amber means “needs assignment” whether it’s a circle on the map or a pill in the list.

Update the page component to add the side panel alongside the map:

 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
def view_template
  div(class: "h-full flex flex-col bg-slate-50") do
    PageHeader(
      title:      "#{@user.depot.name} dispatch",
      subtitle:   "Today's operation in your region",
      breadcrumb: ["Dashboard"]
    )

    render_stat_strip

    div(class: "flex-1 min-h-0 p-6") do
      div(class: "h-full rounded-lg border border-slate-200 bg-white overflow-hidden flex") do
        div(class: "flex-1 min-w-0") { RegionalMap(height: "100%") }
        aside(class: "w-96 border-l border-slate-200 overflow-y-auto") { render_side_panel }
      end
    end
  end
end

private

# ... render_stat_strip unchanged ...

def render_side_panel
  div(class: "p-6 space-y-8") do
    PendingJobsList(total: @data[:pending][:total],
                    items: @data[:pending][:items])
  end
end

The map and sidebar sit side by side inside the same rounded container — visually they read as one composition. The sidebar is fixed-width (w-96), the map fills the rest. On narrow screens the sidebar would feel cramped; Module 7 addresses responsive behaviour.

Look what we just did. Refresh. To the right of the map, a column with the pending jobs section. The header says how many are pending across the whole region, the list shows the six oldest. If a dispatcher’s region has fewer than six pending jobs, the header says “5 total” or similar — no lie about “showing 6”.

Part 3 — Today’s schedule sidebar section

The second sidebar section: jobs scheduled for today, ordered by time, with the assigned FO shown.

Extend the data service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def call(user)
  sa_ids = user.depot.service_areas.select(:id)

  {
    depot_name: user.depot.name,
    counts:          counts(sa_ids),
    pending:         pending_section(sa_ids),
    todays_schedule: todays_schedule_section(sa_ids)
  }
end

# ...

def todays_schedule_section(sa_ids)
  relation = Job.where(service_area_id: sa_ids, status: "scheduled",
                       scheduled_for: Date.current.all_day)
  {
    total: relation.count,
    items: relation.order(scheduled_for: :asc)
                   .limit(6)
                   .includes(:customer, :assigned_to)
  }
end

Earliest first — the next thing to happen sits at the top, ordered by the time each job’s booked for.

The component. Create app/components/todays_schedule_list.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
class Components::TodaysScheduleList < Components::Base
  prop :total, Integer
  prop :items, _Any

  def view_template
    section do
      div(class: "flex items-baseline justify-between mb-3") do
        h2(class: "text-sm font-semibold text-slate-700 uppercase tracking-wider") { "Today's schedule" }
        span(class: "text-xs text-slate-500") { header_count }
      end

      if @items.any?
        ul(class: "space-y-3") do
          @items.each { |job| render_item(job) }
        end
      else
        p(class: "text-sm text-slate-500 italic") { "Nothing scheduled today." }
      end
    end
  end

  private

  def header_count
    if @total <= 5
      "#{@total} total"
    else
      "#{@total} total — showing top 5"
    end
  end

  def render_item(job)
    li(class: "flex items-start justify-between gap-3 pb-3 border-b border-slate-200 last:border-0 last:pb-0") do
      div(class: "min-w-0 flex-1") do
        div(class: "text-sm text-slate-900 font-medium truncate") do
          plain "#{job.customer.name} · #{job.customer.suburb}"
        end
        div(class: "text-sm text-slate-500 font-medium italic truncate") do
          plain "#{job.description}"
        end
        div(class: "text-sm text-slate-500 font-medium truncate") do
          plain "ph: #{job.customer.phone}"
        end
        div(class: "text-xs text-slate-500 truncate mt-0.5") do
          plain "FO: #{job.assigned_to&.name || '—'}"
        end
      end
      div(class: "shrink-0 text-xs text-slate-500 tabular-nums") do
        job.scheduled_for.strftime("%-l:%M%P")
      end
    end
  end
end

The schedule reads as a timeline of the day. Each row shows the customer, the time they’re booked for, and which FO will handle it. No status pill — the section heading already tells you these are scheduled.

Update the side panel to include both sections:

1
2
3
4
5
6
7
8
def render_side_panel
  div(class: "p-6 space-y-8") do
    PendingJobsList(total: @data[:pending][:total],
                    items: @data[:pending][:items])
    TodaysScheduleList(total: @data[:todays_schedule][:total],
                       items: @data[:todays_schedule][:items])
  end
end

Look what we just did. Refresh. The side panel now has two sections: pending jobs at the top, today’s schedule below. The dispatcher’s day reads top to bottom — what needs assigning, then what’s already booked.

The dashboard is complete.

Activity 1 — Switch dispatchers, watch the panels reshape

Sign in as different dispatchers. The map reshapes, but so does everything else: the depot name in the header, the four counts in the strip, the pending list, the day’s schedule. Every panel scopes to the current user’s depot through the same service_area_id IN (...) filter.

The Brisbane dispatcher might have 23 pending jobs and 18 scheduled for today; the Hobart dispatcher might have 4 pending and 6 scheduled. Same code, completely different operational picture.

Activity 2 — Add a depot with no scheduled jobs

In the console:

1
2
3
Job.where(status: "scheduled", scheduled_for: Date.current.all_day,
          service_area: ServiceArea.where(depot_id: Depot.find_by(code: "DAR-01")))
   .destroy_all

Sign in as the Darwin dispatcher. The “Today’s schedule” section now shows “Nothing scheduled today.” — the empty state we built into the component handles the case gracefully. The page doesn’t break; the user gets clear information about why the section is empty.

Where this leaves us

The dispatcher’s dashboard is end-to-end. A header with the depot name, a stats strip narrating the day, a regional map showing the operation, a sidebar surfacing what needs action. Every panel reads from the same data service, scoped through the same FK to the current user’s depot, presented through the same shared design language as the manager’s dashboard.

Two patterns from this lesson worth keeping:

  • One data service per dashboard. DispatcherDashboardData loads everything the page needs — counts, pending jobs, scheduled jobs — in one place. Each panel reads from @data[:something] rather than fetching its own data. This keeps the controller thin and the page component declarative.
  • Empty states are first-class. Every list component has an “if there are no items” branch. Real data is uneven — small depots, quiet days, weekends — and the dashboard has to look right under all of those conditions, not just when the demo data is full.

The next lesson closes the role coverage with the field officer’s dashboard — single SA, today’s queue, the simplest of the three.