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
-
.forecast_by_hour(date) ⇒ Hash{Integer => Float}
Trailing same-weekday average calls per Chicago hour — the demand side of the timeline.
-
.prompt_section(date: Date.current, coverage: nil, forecast: nil) ⇒ String?
Prompt block, or nil when nobody is on queue.
-
.tier(headcount) ⇒ String
The tech team's vocabulary (Basecamp 10137434670).
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.
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.
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".
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 |