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 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, 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