Class: QueueCoverage
- Inherits:
-
Object
- Object
- QueueCoverage
- Defined in:
- app/services/queue_coverage.rb
Overview
Half-hourly on-queue headcount for a phone queue on one day.
Three sources, in order of authority: the work schedule sets the shift, CRM
time off removes whole or partial days, and Google Calendar removes booked
meetings — which is what surfaces "the 3pm sync leaves Jeff on the queue
alone".
Headcount is split into dedicated (this is the rep's only live queue) and
shared (they also answer N others). Coverage is concentrated in a handful
of people who cover up to six queues at once, so a bare headcount overstates
capacity everywhere except Tech, which is nearly single-queue. Fractional
capacity is deliberately not modelled: it assumes uniform call distribution
across a rep's queues, cannot be validated, and fake precision in a staffing
tool is worse than an honest gap.
Lunch needs no special handling: +EmployeeWorkSchedule#hours+ stores a day as
discrete +work+ ranges (07:30–11:30, 12:30–16:30) with the break already
carved out, so a rep is off queue in the gap between two ranges.
Times are minutes-from-midnight in the team's local (Chicago) wall clock,
which is the clock +hours+ is written in.
Defined Under Namespace
Constant Summary collapse
- SLOT_MINUTES =
Slot width for the emitted timeline.
30- TIMEZONE =
The clock every interval here is expressed in — the same one
EmployeeWorkSchedule#hours is written in. 'America/Chicago'- MINUTES_PER_DAY =
End-of-day sentinel: midnight tomorrow is minute 0, not minute 1440, so a
window clipped to the day boundary needs the explicit upper bound. 24 * 60
- WARNING_COVERAGE_PERCENT =
Partial coverage is a warning until fewer than 70% of the displayed
half-hours have anyone on queue. This matches the CRM's established health
scale while reserving green for genuinely complete coverage. 70
Instance Attribute Summary collapse
-
#date ⇒ Object
readonly
Returns the value of attribute date.
-
#queue ⇒ Object
readonly
Returns the value of attribute queue.
-
#zone ⇒ Object
readonly
Returns the value of attribute zone.
Delegated Instance Attributes collapse
-
#empty? ⇒ Object
Alias for Slots#empty?.
Class Method Summary collapse
-
.for_queues(queues, date, zone: TIMEZONE) ⇒ Array<QueueCoverage>
One coverage per queue, sharing a single FreeBusy round trip.
Instance Method Summary collapse
-
#clock(total_minutes) ⇒ String
"HH:MM" in the viewing zone.
-
#clock12(total_minutes) ⇒ String
The same instant on a 12-hour clock.
-
#code_for(employee, taken) ⇒ String
"Jeff Burnett" => "JBUR".
-
#covered_percent ⇒ Integer
Share of the working day somebody — dedicated or shared — is on the queue.
-
#display_minute(minute) ⇒ Integer
Display only.
-
#health ⇒ Symbol
:healthy, :warning or :critical.
-
#health_reason ⇒ String
Names the hours responsible for the rating.
-
#initialize(queue: nil, date: Date.current, employees: nil, busy: nil, zone: TIMEZONE) ⇒ QueueCoverage
constructor
A new instance of QueueCoverage.
-
#on_queue ⇒ Array<Rep>
Roster members with any queue time today.
-
#peak_dedicated ⇒ Integer
The day's best dedicated headcount.
-
#reps ⇒ Array<Rep>
Every roster member, on queue or not.
-
#shifted? ⇒ Boolean
True when labels are not in the zone the shifts are set in.
-
#slots ⇒ Array<Slot>
Spans the whole scheduled day, not just the on-queue parts: a rep booked solid with meetings still has a shift, and the grid has to show those hours as meetings rather than dropping them off the bottom of the table.
-
#unlisted_answerers ⇒ Array<Employee>
Reps answering this queue lately who are not on the roster — overflow or a redirect rather than membership.
Constructor Details
#initialize(queue: nil, date: Date.current, employees: nil, busy: nil, zone: TIMEZONE) ⇒ QueueCoverage
Returns a new instance of QueueCoverage.
171 172 173 174 175 176 177 178 179 |
# File 'app/services/queue_coverage.rb', line 171 def initialize(queue: nil, date: Date.current, employees: nil, busy: nil, zone: TIMEZONE) @queue = queue @date = date.to_date @employees = (employees || queue&.members || []).to_a @busy = busy @zone = zone # work_status_on_day / working_on_day? are per-rep queries otherwise. DailyFocus::ReviewWorkDayPreloader.call(@employees, @date) if @employees.any? end |
Instance Attribute Details
#date ⇒ Object (readonly)
Returns the value of attribute date.
145 146 147 |
# File 'app/services/queue_coverage.rb', line 145 def date @date end |
#queue ⇒ Object (readonly)
Returns the value of attribute queue.
145 146 147 |
# File 'app/services/queue_coverage.rb', line 145 def queue @queue end |
#zone ⇒ Object (readonly)
Returns the value of attribute zone.
145 146 147 |
# File 'app/services/queue_coverage.rb', line 145 def zone @zone end |
Class Method Details
.for_queues(queues, date, zone: TIMEZONE) ⇒ Array<QueueCoverage>
One coverage per queue, sharing a single FreeBusy round trip.
Rosters overlap heavily — Troy alone answers six queues — so building these
one at a time means a live Google call per queue for largely the same
people. The busy hash is keyed by employee id, which is what lets it cross
instances: +queue.members+ returns fresh Employee objects per queue, so the
per-instance work-day preload still has to run against each queue's own
records.
160 161 162 163 164 |
# File 'app/services/queue_coverage.rb', line 160 def self.for_queues(queues, date, zone: TIMEZONE) roster = queues.flat_map(&:members).uniq(&:id) busy = roster.any? ? SchedulerGoogleCalendarService.freebusy_for(roster, date) : {} queues.map { |queue| new(queue: queue, date: date, busy: busy, zone: zone) } end |
Instance Method Details
#clock(total_minutes) ⇒ String
Returns "HH:MM" in the viewing zone.
341 342 343 344 |
# File 'app/services/queue_coverage.rb', line 341 def clock(total_minutes) shown = display_minute(total_minutes) format('%<h>02d:%<m>02d', h: shown / 60, m: shown % 60) end |
#clock12(total_minutes) ⇒ String
The same instant on a 12-hour clock. The grid stays 24-hour — it is twenty
columns wide and "AM"/"PM" in every header is noise — but the briefing reads
as prose and the tech team asked for 12-hour there (Basecamp 10137434670).
352 353 354 355 356 |
# File 'app/services/queue_coverage.rb', line 352 def clock12(total_minutes) shown = display_minute(total_minutes) hour = shown / 60 format('%<h>d:%<m>02d %<p>s', h: (hour % 12).zero? ? 12 : hour % 12, m: shown % 60, p: hour < 12 ? 'AM' : 'PM') end |
#code_for(employee, taken) ⇒ String
"Jeff Burnett" => "JBUR". Four-letter codes keep the timeline scannable.
Two reps can share initial + surname prefix (M. Chen / M. Cheng), so a
collision gets a numeric suffix rather than two indistinguishable rows.
293 294 295 296 297 298 299 |
# File 'app/services/queue_coverage.rb', line 293 def code_for(employee, taken) first, *rest = employee.full_name.to_s.split base = "#{first.to_s[0, 1]}#{rest.last.to_s[0, 3]}".upcase.presence || "EM#{employee.id}" return base unless taken.include?(base) (2..).lazy.map { |n| "#{base}#{n}" }.find { |code| taken.exclude?(code) } end |
#covered_percent ⇒ Integer
Share of the working day somebody — dedicated or shared — is on the queue.
The headline number when several queues are stacked on one page and the
grids are too tall to compare by eye.
240 241 242 243 244 |
# File 'app/services/queue_coverage.rb', line 240 def covered_percent return 0 if slots.empty? (slots.count { |slot| slot.status != :uncovered } * 100.0 / slots.size).round end |
#display_minute(minute) ⇒ Integer
Display only. Every interval in this class is a Chicago wall-clock minute,
because that is the clock +EmployeeWorkSchedule#hours+ is written in, and
rebasing the arithmetic would mean re-deriving shifts from a JSONB that has
no zone in it. So the maths stays Central and only the labels move.
The offset is resolved against +date+ rather than now, so a grid for a day
on the other side of a DST change is still labelled correctly. A zone that
changes offset during the displayed day is not modelled — the day would
need two offsets, and no support shift spans one.
Resolved per slot rather than once for the day. A single midnight offset is
wrong from the moment Central itself changes over: on the US transition day
every slot after 02:00 is an hour out in any zone that does not flip at the
same instant, which is every zone outside North America.
Built from the wall clock rather than by adding minutes to midnight,
because +EmployeeWorkSchedule#hours+ stores wall-clock times: on a
spring-forward day 08:00 is seven hours after midnight, not eight, and
arithmetic on the instant would place the whole shift an hour early.
Memoised because the grid asks per cell — fifteen reps across twenty slots
is three hundred calls, and each one parses.
326 327 328 329 330 331 332 |
# File 'app/services/queue_coverage.rb', line 326 def display_minute(minute) @display_minute ||= {} @display_minute[minute] ||= begin local = central_at(minute).in_time_zone(@zone) (local.hour * 60) + local.min end end |
#empty? ⇒ Object
Alias for Slots#empty?
277 |
# File 'app/services/queue_coverage.rb', line 277 delegate :empty?, to: :slots |
#health ⇒ Symbol
Returns :healthy, :warning or :critical.
247 248 249 250 251 252 |
# File 'app/services/queue_coverage.rb', line 247 def health return :healthy if covered_percent == 100 return :warning if covered_percent >= WARNING_COVERAGE_PERCENT :critical end |
#health_reason ⇒ String
Names the hours responsible for the rating.
A tier on its own tells a manager something is wrong but not what to fix,
and the grid below can be twenty columns wide — long enough that finding the
offending half hours by eye is the work. This is the sentence that goes on
the badge.
262 263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'app/services/queue_coverage.rb', line 262 def health_reason return 'Nobody on this roster is scheduled to work today.' if slots.empty? if health == :healthy [ 'At least one rep is on this queue for every half hour of the day.', lone_cover_sentence, shared_only_sentence ].compact_blank.join(' ') else "No rep is on this queue at #{windows_with(:uncovered)}. Calls in that window ring out." end end |
#on_queue ⇒ Array<Rep>
Returns roster members with any queue time today.
206 207 208 |
# File 'app/services/queue_coverage.rb', line 206 def on_queue @on_queue ||= reps.reject(&:off?) end |
#peak_dedicated ⇒ Integer
Returns the day's best dedicated headcount.
231 232 233 |
# File 'app/services/queue_coverage.rb', line 231 def peak_dedicated slots.map(&:dedicated).max.to_i end |
#reps ⇒ Array<Rep>
Returns every roster member, on queue or not.
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'app/services/queue_coverage.rb', line 182 def reps @reps ||= begin taken = Set.new @employees.map do |employee| code = code_for(employee, taken) taken << code meetings = meetings_for(employee) scheduled = intervals_for(employee) Rep.new( employee: employee, code: code, scheduled: scheduled, intervals: subtract(scheduled, meetings), meetings: meetings, other_queues: other_queue_counts.fetch(employee.id, 0), calendar_unreadable: busy_by_employee.key?(employee.id) && busy_by_employee[employee.id].nil?, work_status: employee.work_status_on_day(@date), time_off: time_off_detail(employee) ) end end end |
#shifted? ⇒ Boolean
Returns true when labels are not in the zone the shifts are set in.
335 336 337 |
# File 'app/services/queue_coverage.rb', line 335 def shifted? @zone.to_s != TIMEZONE end |
#slots ⇒ Array<Slot>
Spans the whole scheduled day, not just the on-queue parts: a rep booked
solid with meetings still has a shift, and the grid has to show those hours
as meetings rather than dropping them off the bottom of the table.
215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'app/services/queue_coverage.rb', line 215 def slots @slots ||= begin bounds = reps.flat_map(&:scheduled) if bounds.empty? [] else first = bounds.map(&:first).min first -= first % SLOT_MINUTES (first...bounds.map(&:last).max).step(SLOT_MINUTES).map do |minute| Slot.new(minute: minute, reps: reps.select { |rep| rep.on_queue_at?(minute) }) end end end end |
#unlisted_answerers ⇒ Array<Employee>
Reps answering this queue lately who are not on the roster — overflow or a
redirect rather than membership. Surfaced, never counted.
282 283 284 |
# File 'app/services/queue_coverage.rb', line 282 def unlisted_answerers @unlisted_answerers ||= queue ? queue.unlisted_answerers : [] end |