Class: QueueCoverage

Inherits:
Object
  • Object
show all
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

Classes: Rep, Slot

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

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(queue: nil, date: Date.current, employees: nil, busy: nil, zone: TIMEZONE) ⇒ QueueCoverage

Returns a new instance of QueueCoverage.

Parameters:

  • queue (PhoneQueue, nil) (defaults to: nil)

    supplies the roster and the shared-queue counts

  • date (Date) (defaults to: Date.current)
  • employees (Enumerable<Employee>, nil) (defaults to: nil)

    overrides the queue's roster

  • busy (Hash{Integer => Array<Hash>, nil}, nil) (defaults to: nil)

    prefetched FreeBusy, see .for_queues

  • zone (String) (defaults to: TIMEZONE)

    IANA name the timeline is displayed in, see #display_minute



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

#dateObject (readonly)

Returns the value of attribute date.



145
146
147
# File 'app/services/queue_coverage.rb', line 145

def date
  @date
end

#queueObject (readonly)

Returns the value of attribute queue.



145
146
147
# File 'app/services/queue_coverage.rb', line 145

def queue
  @queue
end

#zoneObject (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.

Parameters:

  • queues (Array<PhoneQueue>)
  • date (Date)
  • zone (String) (defaults to: TIMEZONE)

    IANA name the timeline is displayed in

Returns:



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.

Parameters:

  • total_minutes (Integer)

    Central minutes from midnight

Returns:

  • (String)

    "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).

Parameters:

  • total_minutes (Integer)

    Central minutes from midnight

Returns:

  • (String)

    "H:MM AM" in the viewing zone



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.

Parameters:

  • employee (Employee)
  • taken (Set<String>)

    codes already assigned

Returns:

  • (String)


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_percentInteger

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.

Returns:

  • (Integer)

    0-100



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.

Parameters:

  • minute (Integer)

    Central minutes from midnight

Returns:

  • (Integer)

    the same instant as a minute in +zone+



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?

Returns:

  • (Object)

    Slots#empty?

See Also:



277
# File 'app/services/queue_coverage.rb', line 277

delegate :empty?, to: :slots

#healthSymbol

Returns :healthy, :warning or :critical.

Returns:

  • (Symbol)

    :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_reasonString

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.

Returns:

  • (String)


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_queueArray<Rep>

Returns roster members with any queue time today.

Returns:

  • (Array<Rep>)

    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_dedicatedInteger

Returns the day's best dedicated headcount.

Returns:

  • (Integer)

    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

#repsArray<Rep>

Returns every roster member, on queue or not.

Returns:

  • (Array<Rep>)

    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.

Returns:

  • (Boolean)

    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

#slotsArray<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.

Returns:

  • (Array<Slot>)

    half-hour buckets across the roster's working window



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_answerersArray<Employee>

Reps answering this queue lately who are not on the roster — overflow or a
redirect rather than membership. Surfaced, never counted.

Returns:



282
283
284
# File 'app/services/queue_coverage.rb', line 282

def unlisted_answerers
  @unlisted_answerers ||= queue ? queue.unlisted_answerers : []
end