Module: DailyFocus::CoverageSection

Defined in:
app/services/daily_focus/coverage_section.rb

Overview

Section 1 of the support briefing — rendered, not described.

The calculation belongs to QueueCoverage, which the CRM dashboard also
drives; this is only the LLM-facing formatting. It used to hand the model a
list of half-hour slot lines and ask for "one row per hour". The model
bridged that gap by guessing, so two briefings built from the same table
disagreed about headcount, invented their own tier words and colours, and
emitted overlapping time ranges — and a rep's lunch read as an unexplained
gap (Basecamp 10137434670, 2026-08-10). Interval arithmetic and tier
labelling are things a model gets subtly wrong, and wrong staffing advice is
worse than none, so the table now arrives finished and the model writes only
the notes column.

Constant Summary collapse

TECH_QUEUE_EXTENSION =

The support briefing is a tech-team document, so it reads the Tech 1
queue. Falls back to the audience's rep scope when the queue roster is
empty, which keeps the briefing working if PBX membership goes missing.

613
FORECAST_EXTENSIONS =

Both tech queues feed the demand side: 613 carries the daytime volume,
600 the overflow. Mirrors SupportSections::QUEUE_EXTENSIONS.

[600, 613].freeze
FORECAST_DAYS =

Trailing same-weekday window behind the per-hour call forecast.

56
LOCAL_TIME_SQL =

queue_call_logs.start_time is naive UTC and every hour bucket here is
Chicago wall clock, so convert explicitly rather than trusting the session
TimeZone (which silently makes the legacy report's cast a no-op).

"(start_time AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')"

Class Method Summary collapse

Class Method Details

.forecast_by_hour(date) ⇒ Hash{Integer => Float}

Trailing same-weekday average calls per Chicago hour — the demand side of
the timeline. Computed here rather than asked for as a tool call: the
model would otherwise have to join it onto the coverage table by hand,
which is where the invented rows came from.

The denominator is every same-weekday day in the window that saw traffic,
NOT the days that happened to have a call in this hour. Dividing per
hour is how the legacy report made 09:00 the "peak call hour" at 4.5
calls: nine calls fell on two of the last eight Mondays, so six silent
Mondays never entered the average. Over eight, it is 1.1, and the real
busiest hour is 14:00. A quiet hour is data, not an absent sample.

Counted from the rows rather than assumed to be FORECAST_DAYS / 7, so a
queue with three weeks of history — or a holiday that took a whole
weekday out — divides by the days we actually have.

Parameters:

  • date (Date)

Returns:

  • (Hash{Integer => Float})

    Chicago hour => average calls



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'app/services/daily_focus/coverage_section.rb', line 92

def self.forecast_by_hour(date)
  hour = Arel.sql("EXTRACT(HOUR FROM #{LOCAL_TIME_SQL})")
  day = Arel.sql("#{LOCAL_TIME_SQL}::date")

  # One row per (hour, day) — at most FORECAST_DAYS / 7 days of buckets, so
  # the division is cheaper in Ruby than a second round trip for the count.
  buckets = QueueCallLog.where(queue_extension: FORECAST_EXTENSIONS)
                        .where("#{LOCAL_TIME_SQL}::date BETWEEN ? AND ?", date - FORECAST_DAYS, date - 1)
                        .where("EXTRACT(DOW FROM #{LOCAL_TIME_SQL}) = ?", date.wday)
                        .group(hour, day)
                        .count
  days = buckets.keys.map(&:last).uniq.size
  return {} if days.zero?

  buckets.each_with_object(Hash.new(0)) { |((bucket, _), calls), totals| totals[bucket.to_i] += calls }
         .transform_values { |calls| (calls.to_f / days).round(1) }
end

.prompt_section(date: Date.current, coverage: nil, forecast: nil) ⇒ String?

Returns prompt block, or nil when nobody is on queue.

Parameters:

  • date (Date) (defaults to: Date.current)
  • coverage (QueueCoverage, nil) (defaults to: nil)

    injectable for tests

  • forecast (Hash{Integer => Float}, nil) (defaults to: nil)

    injectable for tests

Returns:

  • (String, nil)

    prompt block, or nil when nobody is on queue



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'app/services/daily_focus/coverage_section.rb', line 55

def self.prompt_section(date: Date.current, coverage: nil, forecast: nil)
  coverage ||= build(date)
  return nil if coverage.empty?

  forecast ||= forecast_by_hour(coverage.date)

  <<~TEXT
    SECTION 1 — ROLLING QUEUE COVERAGE. The table below IS Section 1 and it is already finished. Reproduce every row verbatim and in order under the "SECTION 1 — Rolling Queue Coverage" heading, and write only the "Operational notes" cell. Do NOT merge or re-time rows, recount a headcount, rename a tier, change the clock format, add or drop a rep, or recompute any of this from another tool — it is precomputed from CRM work schedules, approved time off, each rep's Google Calendar, and #{FORECAST_DAYS / 7} weeks of this weekday's call history for #{coverage.date.strftime('%A, %B %-d, %Y')}, America/Chicago. Meeting titles are deliberately withheld — call them "a meeting", never guess a subject.
    Roster: #{roster_lines(coverage).join(' · ')}

    | Time (CT) | Coverage | On-queue roster | Off queue | Fcst calls/hr | Operational notes |
    | --- | --- | --- | --- | --- | --- |
    #{table_rows(coverage, forecast).join("\n")}

    Legend: the tier counts everyone on queue. "CODE+N" also answers N other queues, so they are not a whole rep of capacity. "Off queue" lists only reps who are on shift but unavailable — their lunch or a meeting — so a gap is never reported as an unexplained absence.
    Write each "Operational notes" cell yourself, in a short phrase: when to run QC reviews or clear backlog, when NOT to go offline, which rows pair the heaviest forecast with the thinnest coverage, and any structural fix worth suggesting (moving a meeting or a lunch out of a 1-rep row). Recommendations live in that column — do not add a separate recommendations or alerts section.
  TEXT
end

.tier(headcount) ⇒ String

The tech team's vocabulary (Basecamp 10137434670). Keyed on bodies on
queue rather than on the dedicated/shared split the dashboard tints by: a
rep who also answers Management is still answering Tech, and the team
reads the number as "how many people are on".

Parameters:

  • headcount (Integer)

Returns:

  • (String)

    emoji + word, so the tier never rests on colour alone



41
42
43
44
45
46
47
48
49
# File 'app/services/daily_focus/coverage_section.rb', line 41

def self.tier(headcount)
  case headcount
  when 0 then '⚫ UNCOVERED'
  when 1 then '🔴 CRITICAL'
  when 2 then '🟠 THIN'
  when 3 then '🟡 STEADY'
  else '🟢 PEAK'
  end
end