import { ClosedQuestionStatus , Heatmap , timeDiffInMins } from '@koh/common' ; import { CACHE_MANAGER , Inject , Injectable } from '@nestjs/common' ; import { inRange , mean , range } from 'lodash' ; import moment = require ( 'moment' ) ; import { Command , Positional } from 'nestjs-command' ; import { QuestionModel } from 'question/question.entity' ; import { MoreThan } from 'typeorm' ; import { OfficeHourModel } from './office-hour.entity' ; import { Cache } from 'cache-manager' ; import { CourseModel } from './course.entity' ; function arrayRotate ( arr , count ) { count -= arr . length * Math . floor ( count / arr . length ) ; const spliced = arr . splice ( 0 , count ) ; return [ ... arr , ... spliced ] ; } @ Injectable ( ) export class HeatmapService { constructor ( @ Inject ( CACHE_MANAGER ) private cacheManager ) { } async getCachedHeatmapFor ( courseId ) { const cacheLengthInSeconds = 604800 ; return this . cacheManager . wrap ( `heatmap/${ courseId }` , ( ) => this . _getHeatmapFor ( courseId ) , { ttl : cacheLengthInSeconds } ) ; } async _getHeatmapFor ( courseId ) { const BUCKET_SIZE_IN_MINS = 15 ; const SAMPLES_PER_BUCKET = 3 ; console . time ( 'heatmap' ) ; const recent = moment ( ) . subtract ( 8 , 'weeks' ) . toISOString ( ) ; const questions = await QuestionModel . createQueryBuilder ( 'question' ) . leftJoinAndSelect ( 'question.queue' , 'queue' ) . where ( 'queue.courseId = :courseId' , { courseId } ) . andWhere ( 'question.status = :status' , { status : ClosedQuestionStatus . Resolved , } ) . andWhere ( 'question.helpedAt IS NOT NULL' ) . andWhere ( 'question.createdAt > :recent' , { recent } ) . orderBy ( 'question.createdAt' , 'ASC' ) . getMany ( ) ; if ( questions . length === 0 ) { return false ; } const officeHours = await OfficeHourModel . find ( { where : { startTime : MoreThan ( recent ) , courseId } , } ) ; if ( officeHours . length === 0 ) { return false ; } const tz = ( await CourseModel . findOne ( { id : courseId } ) ) . timezone ; let heatmap = this . _generateHeatMapWithReplay ( questions . filter ( ( q ) => q . helpedAt . getDate ( ) === q . createdAt . getDate ( ) ) , officeHours , tz , BUCKET_SIZE_IN_MINS , SAMPLES_PER_BUCKET ) ; heatmap = arrayRotate ( heatmap , - moment . tz . zone ( tz ) . utcOffset ( Date . now ( ) ) / BUCKET_SIZE_IN_MINS ) ; console . timeEnd ( 'heatmap' ) ; return heatmap ; } _generateHeatMapWithReplay ( questions , hours , timezone , bucketSize , samplesPerBucket ) { const sampleInterval = bucketSize / samplesPerBucket ; const hourTimestamps = hours . map ( ( hours ) => [ hours . startTime . getTime ( ) , hours . endTime . getTime ( ) , ] ) ; function dateToBucket ( date ) { const cInZone = moment . tz ( date , timezone ) ; return Math . floor ( ( cInZone . day ( ) * 24 * 60 + cInZone . hour ( ) * 60 + cInZone . minute ( ) ) / bucketSize ) ; } const timepointBuckets = [ ... Array ( ( 24 * 7 * 60 ) / bucketSize ) , ] . map ( ( ) => [ ] ) ; if ( questions . length ) { const startDate = questions [ 0 ] . createdAt ; const sunday = moment . tz ( startDate , timezone ) . startOf ( 'week' ) . toDate ( ) ; function getNextTimepointIndex ( date ) { return Math . floor ( timeDiffInMins ( date , sunday ) / sampleInterval ) + 1 ; } function getNextSampleTimepoint ( date ) { const timepointIndex = getNextTimepointIndex ( date ) ; return new Date ( sunday . getTime ( ) + timepointIndex * sampleInterval * 60 * 1000 ) ; } function getSampleTimepointsInDateRange ( date1 , date2 ) { const ret = [ ] ; let curr = getNextSampleTimepoint ( date1 ) ; while ( curr . getTime ( ) < date2 . getTime ( ) ) { ret . push ( curr ) ; curr = getNextSampleTimepoint ( curr ) ; } return ret ; } function lastBucketBoundary ( date ) { const startOfWeek = moment . tz ( date , timezone ) . startOf ( 'week' ) ; const m = moment ( date ) ; return m . subtract ( m . diff ( startOfWeek , 'm' ) % bucketSize , 'm' ) ; } let isFirst = true ; for ( let i = 0 ; i < questions . length ; i ++ ) { const curr = questions [ i ] ; const next = questions [ i + 1 ] ; const isLast = i === questions . length - 1 ; let sampledTimepoints = getSampleTimepointsInDateRange ( isFirst ? lastBucketBoundary ( curr . createdAt ) . subtract ( 1 , 's' ) . toDate ( ) : curr . createdAt , isLast ? lastBucketBoundary ( curr . helpedAt ) . add ( bucketSize , 'm' ) . toDate ( ) : next . createdAt ) ; sampledTimepoints = sampledTimepoints . filter ( ( time ) => hourTimestamps . some ( ( [ start , end ] ) => inRange ( time . getTime ( ) , start , end ) ) ) ; if ( sampledTimepoints . length > 0 && isFirst ) { isFirst = false ; } for ( const c of sampledTimepoints ) { let wait = 0 ; if ( inRange ( c . getTime ( ) , curr . createdAt . getTime ( ) , curr . helpedAt . getTime ( ) ) ) { wait = ( curr . helpedAt . getTime ( ) - c . getTime ( ) ) / 60000 ; } const bucketIndex = dateToBucket ( c ) ; timepointBuckets [ bucketIndex ] . push ( wait ) ; } } } const wereHoursDuringBucket = [ ... Array ( ( 24 * 7 * 60 ) / bucketSize ) , ] ; for ( const [ start , end ] of hourTimestamps ) { for ( const i of range ( dateToBucket ( start ) , dateToBucket ( end - 1 ) + 1 ) ) { wereHoursDuringBucket [ i ] = true ; } } const h = timepointBuckets . map ( ( samples , i ) => { if ( samples . length > 0 ) { return mean ( samples ) ; } else if ( wereHoursDuringBucket [ i ] ) { return 0 ; } else { return - 1 ; } } ) ; return h ; } @ Command ( { command : 'heatmap:generate <courseId>' , describe : 'generate heatmap for a course' , autoExit : true , } ) async create ( @ Positional ( { name : 'courseId' , describe : 'which course the heatmap will be generated for' , type : 'number' , } ) courseId ) { console . log ( await this . _getHeatmapFor ( courseId ) ) ; } }