325 lines
11 KiB
Ruby
325 lines
11 KiB
Ruby
module JamRuby
|
||
class LessonSessionAnalyser
|
||
|
||
SUCCESS = 'success'
|
||
SESSION_ONGOING = 'session_ongoing'
|
||
THRESHOLD_MET = 'threshold_met'
|
||
WAITED_CORRECTLY = 'waited_correctly'
|
||
MINIMUM_TIME_MET = 'minimum_time_met' # for a teacher primarily; they waited around for the student sufficiently
|
||
MINIMUM_TIME_NOT_MET = 'mininum_time_not_met'
|
||
LATE_CANCELLATION = 'late_cancellation'
|
||
|
||
TEACHER_FAULT = 'teacher_fault'
|
||
STUDENT_FAULT = 'student_fault'
|
||
BOTH_FAULT = 'both_fault'
|
||
|
||
STUDENT_NOT_THERE_WHEN_JOINED = 'student_not_there_when_joined'
|
||
JOINED_LATE = 'did_not_join_on_time'
|
||
NO_SHOW = 'no_show'
|
||
NEITHER_SHOW = 'neither_show'
|
||
|
||
|
||
# what are the potential results?
|
||
|
||
# bill: true/false
|
||
|
||
# teacher: 'no_show'
|
||
# teacher: 'late'
|
||
# teacher: 'early_leave'
|
||
# teacher: 'waited_correctly'
|
||
# teacher: 'late_cancellation'
|
||
|
||
# student: 'no_show'
|
||
# student: 'late'
|
||
# student: 'early_leave'
|
||
# student: 'minimum_time_not_met'
|
||
# student: 'threshold_met'
|
||
|
||
|
||
# reason: 'session_ongoing'
|
||
# reason: 'success'
|
||
# reason: 'student_fault'
|
||
# reason: 'teacher_fault'
|
||
# reason: 'both_fault'
|
||
|
||
|
||
def self.analyse(lesson_session, force = false)
|
||
reason = nil
|
||
teacher = nil
|
||
student = nil
|
||
bill = false
|
||
|
||
music_session = lesson_session.music_session
|
||
|
||
student_histories = MusicSessionUserHistory.where(music_session_id: music_session.id, user_id: lesson_session.student.id)
|
||
teacher_histories = MusicSessionUserHistory.where(music_session_id: music_session.id, user_id: lesson_session.teacher.id)
|
||
|
||
# create ranges from music session user history
|
||
all_student_ranges = time_ranges(student_histories)
|
||
all_teacher_ranges = time_ranges(teacher_histories)
|
||
|
||
# flatten ranges into non-overlapping ranges to simplifly logic
|
||
student_ranges = merge_overlapping_ranges(all_student_ranges)
|
||
teacher_ranges = merge_overlapping_ranges(all_teacher_ranges)
|
||
|
||
intersecting = intersecting_ranges(student_ranges, teacher_ranges)
|
||
student_analysis = analyse_intersection(lesson_session, student_ranges)
|
||
teacher_analysis = analyse_intersection(lesson_session, teacher_ranges)
|
||
together_analysis = analyse_intersection(lesson_session, intersecting)
|
||
|
||
# spec: https://jamkazam.atlassian.net/wiki/display/PS/Product+Specification+-+JamClass#ProductSpecification-JamClass-TeacherReceives&RespondstoLessonBookingRequest
|
||
|
||
if !force && !((music_session.scheduled_start + (lesson_session.duration * 60)) < Time.now)
|
||
reason = SESSION_ONGOING
|
||
bill = false
|
||
else
|
||
#if lesson_session.is_canceled? && lesson_session.canceled_by_teacher? && lesson_session.canceled_late?
|
||
# # If the lesson was cancelled less than 24 hours before the start time by the teacher, then we do not bill the student.
|
||
# teacher = LATE_CANCELLATION
|
||
# bill = false
|
||
#elsif lesson_session.is_canceled? && lesson_session.canceled_by_student? && lesson_session.canceled_late?
|
||
# # If the lesson was cancelled less than 24 hours before the start time by the student (if that is even possible, I can’t remember now), then we do bill the student.
|
||
# student = LATE_CANCELLATION
|
||
# bill = true
|
||
if together_analysis[:session_time] / 60 > APP_CONFIG.lesson_together_threshold_minutes
|
||
bill = true
|
||
reason = SUCCESS
|
||
elsif teacher_analysis[:joined_on_time] && teacher_analysis[:waited_correctly]
|
||
# if the teacher was present in the session within the first 5 minutes of the scheduled start time and stayed in the session for 10 minutes;
|
||
# and if either:
|
||
|
||
if student_analysis[:no_show]
|
||
# the student no-showed entirely, then we bill the student.
|
||
student = NO_SHOW
|
||
bill = true
|
||
|
||
elsif student_analysis[:joined_late]
|
||
# the student joined the lesson more than 10 minutes after the teacher did, regardless of whether the teacher was still in the lesson session at that point; then we bill the student
|
||
student = JOINED_LATE
|
||
bill = true
|
||
end
|
||
else
|
||
if teacher_analysis[:no_show]
|
||
teacher = NO_SHOW
|
||
elsif !teacher_analysis[:joined_on_time]
|
||
teacher = JOINED_LATE
|
||
elsif !teacher_analysis[:waited_correctly]
|
||
teacher = MINIMUM_TIME_NOT_MET
|
||
end
|
||
end
|
||
|
||
|
||
end
|
||
|
||
if reason.nil?
|
||
if student
|
||
reason = STUDENT_FAULT
|
||
elsif teacher
|
||
reason = TEACHER_FAULT
|
||
else
|
||
reason = NEITHER_SHOW
|
||
end
|
||
end
|
||
|
||
|
||
{
|
||
reason: reason,
|
||
teacher: teacher,
|
||
student: student,
|
||
bill: bill,
|
||
student_ranges: student_ranges,
|
||
teacher_ranges: teacher_ranges,
|
||
intersecting: intersecting,
|
||
student_analysis: student_analysis,
|
||
teacher_analysis: teacher_analysis,
|
||
together_analysis: together_analysis,
|
||
}
|
||
end
|
||
|
||
def self.annotate_timeline(lesson_session, analysis, ranges)
|
||
start = lesson_session.scheduled_start
|
||
end
|
||
|
||
def self.intersecting_ranges(ranges_a, ranges_b)
|
||
intersections = []
|
||
ranges_a.each do |range_a|
|
||
ranges_b.each do |range_b|
|
||
intersection = intersect(range_a, range_b)
|
||
intersections << intersection if intersection
|
||
end
|
||
end
|
||
|
||
merge_overlapping_ranges(intersections)
|
||
end
|
||
|
||
# needs to add to joined_on_time
|
||
# joined_on_time bool
|
||
# waited_correctly bool
|
||
# no_show bool
|
||
# joined_late bool
|
||
# minimum_time_met bool
|
||
# present_at_end bool
|
||
|
||
|
||
def self.analyse_intersection(lesson_session, ranges)
|
||
# be sure to call .to_time on any ActiveRecord time, because we get a ton of deprecation warninsg about Time#succ if you use ActiveSupport:: TimeZone
|
||
start = lesson_session.scheduled_start.to_time
|
||
planned_duration_seconds = lesson_session.duration * 60
|
||
end_time = start + planned_duration_seconds
|
||
|
||
join_start_boundary_begin = start
|
||
join_start_boundary_end = start + (APP_CONFIG.lesson_join_time_window_minutes * 60)
|
||
|
||
wait_boundary_begin = start
|
||
wait_boundary_end = start + (APP_CONFIG.lesson_wait_time_window_minutes * 60)
|
||
|
||
initial_join_window = Range.new(join_start_boundary_begin, join_start_boundary_end)
|
||
initial_wait_window = Range.new(wait_boundary_begin, wait_boundary_end)
|
||
session_window = Range.new(start, end_time)
|
||
|
||
# let's see how much time they spent together, irrespective of scheduled time
|
||
# and also, based on scheduled time
|
||
total = 0
|
||
|
||
in_scheduled_time = 0
|
||
in_wait_window_time = 0
|
||
|
||
# the initial time joined in the initial 'waiting window'
|
||
initial_join_in_scheduled_time = nil
|
||
|
||
# the amount of time spent in the initial 'waiting window'
|
||
initial_wait_time_in_scheduled_time = 0
|
||
|
||
last_wait_time_out = nil
|
||
|
||
joined_on_time = false
|
||
waited_correctly = false
|
||
no_show = true
|
||
joined_late = false
|
||
joined_in_wait_window = false
|
||
ranges.each do |range|
|
||
time = range.end - range.begin
|
||
|
||
total += time
|
||
|
||
in_session_range = intersect(range, session_window)
|
||
in_join_window_range = intersect(range, initial_join_window)
|
||
in_wait_window_range = intersect(range, initial_wait_window)
|
||
|
||
if in_session_range
|
||
in_scheduled_time += in_session_range.end - in_session_range.begin
|
||
no_show = false
|
||
end
|
||
|
||
if in_join_window_range
|
||
if initial_join_in_scheduled_time.nil?
|
||
initial_join_in_scheduled_time = in_join_window_range.begin
|
||
end
|
||
joined_on_time = true
|
||
end
|
||
|
||
if in_wait_window_range
|
||
in_wait_window_time += in_wait_window_range.end - in_wait_window_range.begin
|
||
last_wait_time_out = range.end
|
||
joined_in_wait_window = true
|
||
end
|
||
end
|
||
|
||
if joined_in_wait_window && !joined_on_time
|
||
joined_late = true
|
||
end
|
||
|
||
if last_wait_time_out && last_wait_time_out > wait_boundary_end
|
||
last_wait_time_out = wait_boundary_end
|
||
end
|
||
|
||
initial_waiting_time_pct = nil
|
||
potential_waiting_time = nil
|
||
|
||
# let's see if this person was hanging around for the bulk of this waiting window (to rule out someone coming/going very fast, trying to miss someone)
|
||
if last_wait_time_out && initial_join_in_scheduled_time
|
||
total_in_waiting_time = 0
|
||
potential_waiting_range = Range.new(initial_join_in_scheduled_time, last_wait_time_out)
|
||
ranges.each do |range|
|
||
in_waiting = intersect(potential_waiting_range, range)
|
||
if in_waiting
|
||
total_in_waiting_time += in_waiting.end - in_waiting.begin
|
||
end
|
||
end
|
||
|
||
potential_waiting_time = last_wait_time_out - initial_join_in_scheduled_time
|
||
initial_waiting_time_pct = total_in_waiting_time.to_f / potential_waiting_time.to_f
|
||
|
||
# finally with all this stuff calculated, we can check:
|
||
# 1) did they wait a solid % of time between the time they joined, and left, during the initial 10 minute waiting window?
|
||
# 2) did they
|
||
if (initial_waiting_time_pct >= APP_CONFIG.wait_time_window_pct) &&
|
||
(last_wait_time_out >= (wait_boundary_end - (APP_CONFIG.end_of_wait_window_forgiveness_minutes * 60)))
|
||
waited_correctly = true
|
||
end
|
||
end
|
||
|
||
|
||
# percentage computation of time spent during the session time
|
||
in_scheduled_percentage = in_scheduled_time.to_f / planned_duration_seconds.to_f
|
||
|
||
# missed is an aggregrate concept shown in the UI often
|
||
# if you were a no show, or joined late, or didn't wait correctly, then you 'missed'
|
||
missed = no_show || joined_late || !waited_correctly
|
||
|
||
joined_on_time = joined_on_time
|
||
{
|
||
total_time: total,
|
||
session_time: in_scheduled_time,
|
||
session_pct: in_scheduled_percentage,
|
||
joined_on_time: joined_on_time,
|
||
waited_correctly: waited_correctly,
|
||
no_show: no_show,
|
||
joined_late: joined_late,
|
||
missed: missed,
|
||
initial_join_in_scheduled_time: initial_join_in_scheduled_time,
|
||
last_wait_time_out: last_wait_time_out,
|
||
in_wait_window_time: in_wait_window_time,
|
||
initial_waiting_pct: initial_waiting_time_pct,
|
||
potential_waiting_time: potential_waiting_time
|
||
}
|
||
end
|
||
|
||
def self.intersect(a, b)
|
||
min, max = a.first, a.exclude_end? ? a.max : a.last
|
||
other_min, other_max = b.first, b.exclude_end? ? b.max : b.last
|
||
|
||
new_min = a === other_min ? other_min : b === min ? min : nil
|
||
new_max = a === other_max ? other_max : b === max ? max : nil
|
||
|
||
new_min && new_max ? Range.new(new_min, new_max) : nil
|
||
end
|
||
|
||
def self.time_ranges(histories)
|
||
ranges = []
|
||
histories.each do |history|
|
||
ranges << history.range
|
||
end
|
||
ranges
|
||
end
|
||
|
||
def self.ranges_overlap?(a, b)
|
||
a.cover?(b.begin) || b.cover?(a.begin)
|
||
end
|
||
|
||
def self.merge_ranges(a, b)
|
||
[a.begin, b.begin].min..[a.end, b.end].max
|
||
end
|
||
|
||
def self.merge_overlapping_ranges(ranges)
|
||
ranges.sort_by(&:begin).inject([]) do |ranges, range|
|
||
if !ranges.empty? && ranges_overlap?(ranges.last, range)
|
||
ranges[0...-1] + [merge_ranges(ranges.last, range)]
|
||
else
|
||
ranges + [range]
|
||
end
|
||
end
|
||
end
|
||
|
||
end
|
||
end |