CocoCollection = require 'collections/CocoCollection' Course = require 'models/Course' CourseInstance = require 'models/CourseInstance' require 'vendor/d3' d3Utils = require 'core/d3_utils' RootView = require 'views/core/RootView' template = require 'templates/admin/analytics' utils = require 'core/utils' module.exports = class AnalyticsView extends RootView id: 'admin-analytics-view' template: template furthestCourseDayRange: 30 lineColors: ['red', 'blue', 'green', 'purple', 'goldenrod', 'brown', 'darkcyan'] minSchoolCount: 20 constructor: (options) -> super options @loadData() getRenderData: -> context = super() context.activeClasses = @activeClasses ? [] context.activeClassGroups = @activeClassGroups ? {} context.activeUsers = @activeUsers ? [] context.revenue = @revenue ? [] context.revenueGroups = @revenueGroups ? {} context afterRender: -> super() @createLineCharts() loadData: -> @supermodel.addRequestResource('active_classes', { url: '/db/analytics_perday/-/active_classes' method: 'POST' success: (data) => # Organize data by day, then group groupMap = {} dayGroupMap = {} for activeClass in data dayGroupMap[activeClass.day] ?= {} dayGroupMap[activeClass.day]['Total'] = 0 for group, val of activeClass.classes groupMap[group] = true dayGroupMap[activeClass.day][group] = val dayGroupMap[activeClass.day]['Total'] += val @activeClassGroups = Object.keys(groupMap) @activeClassGroups.push 'Total' # Build list of active classes, where each entry is a day of individual group values @activeClasses = [] for day of dayGroupMap dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}" data = day: dashedDay, groups: [] for group in @activeClassGroups data.groups.push(dayGroupMap[day][group] ? 0) @activeClasses.push data @activeClasses.sort (a, b) -> b.day.localeCompare(a.day) @updateAllKPIChartData() @updateActiveClassesChartData() @render?() }, 0).load() @supermodel.addRequestResource('active_users', { url: '/db/analytics_perday/-/active_users' method: 'POST' success: (data) => @activeUsers = data.map (a) -> a.day = "#{a.day.substring(0, 4)}-#{a.day.substring(4, 6)}-#{a.day.substring(6, 8)}" a @activeUsers.sort (a, b) -> b.day.localeCompare(a.day) @updateAllKPIChartData() @updateActiveUsersChartData() @render?() }, 0).load() @supermodel.addRequestResource('recurring_revenue', { url: '/db/analytics_perday/-/recurring_revenue' method: 'POST' success: (data) => # Organize data by day, then group groupMap = {} dayGroupCountMap = {} for dailyRevenue in data dayGroupCountMap[dailyRevenue.day] ?= {} dayGroupCountMap[dailyRevenue.day]['Daily'] = 0 for group, val of dailyRevenue.groups groupMap[group] = true dayGroupCountMap[dailyRevenue.day][group] = val dayGroupCountMap[dailyRevenue.day]['Daily'] += val @revenueGroups = Object.keys(groupMap) @revenueGroups.push 'Daily' # Build list of recurring revenue entries, where each entry is a day of individual group values @revenue = [] for day of dayGroupCountMap dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}" data = day: dashedDay, groups: [] for group in @revenueGroups data.groups.push(dayGroupCountMap[day][group] ? 0) @revenue.push data @revenue.sort (a, b) -> b.day.localeCompare(a.day) return unless @revenue.length > 0 # Add monthly recurring revenue values @revenueGroups.push 'Monthly' monthlyValues = [] for i in [@revenue.length-1..0] dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 1] monthlyValues.push(dailyTotal) monthlyValues.shift() while monthlyValues.length > 30 if monthlyValues.length is 30 @revenue[i].groups.push(_.reduce(monthlyValues, (s, num) -> s + num)) @updateAllKPIChartData() @updateRevenueChartData() @render?() }, 0).load() @supermodel.addRequestResource('school_counts', { url: '/db/user/-/school_counts' method: 'POST' data: {minCount: @minSchoolCount} success: (@schoolCounts) => @schoolCounts?.sort (a, b) -> return -1 if a.count > b.count return 0 if a.count is b.count 1 @render?() }, 0).load() @courses = new CocoCollection([], { url: "/db/course", model: Course}) @courses.comparator = "_id" @listenToOnce @courses, 'sync', @onCoursesSync @supermodel.loadCollection(@courses, 'courses') onCoursesSync: -> # Assumes courses retrieved in order @courseOrderMap = {} @courseOrderMap[@courses.models[i].get('_id')] = i for i in [0...@courses.models.length] startDay = new Date() startDay.setUTCDate(startDay.getUTCDate() - @furthestCourseDayRange) startDay = startDay.toISOString().substring(0, 10) options = url: '/db/course_instance/-/recent' method: 'POST' data: {startDay: startDay} options.error = (models, response, options) => return if @destroyed console.error 'Failed to get recent course instances', response options.success = (models) => @courseInstances = models ? [] @onCourseInstancesSync() @render?() @supermodel.addRequestResource('get_recent_course_instances', options, 0).load() onCourseInstancesSync: -> return unless @courseInstances # Find highest course for teachers and students @teacherFurthestCourseMap = {} @studentFurthestCourseMap = {} for courseInstance in @courseInstances courseID = courseInstance.courseID teacherID = courseInstance.ownerID if not @teacherFurthestCourseMap[teacherID] or @teacherFurthestCourseMap[teacherID] < @courseOrderMap[courseID] @teacherFurthestCourseMap[teacherID] = @courseOrderMap[courseID] for studentID in courseInstance.members if not @studentFurthestCourseMap[studentID] or @studentFurthestCourseMap[studentID] < @courseOrderMap[courseID] @studentFurthestCourseMap[studentID] = @courseOrderMap[courseID] @teacherCourseDistribution = {} for teacherID, courseIndex of @teacherFurthestCourseMap @teacherCourseDistribution[courseIndex] ?= 0 @teacherCourseDistribution[courseIndex]++ @studentCourseDistribution = {} for studentID, courseIndex of @studentFurthestCourseMap @studentCourseDistribution[courseIndex] ?= 0 @studentCourseDistribution[courseIndex]++ createLineChartPoints: (days, data) -> points = [] for entry, i in data points.push day: entry.day y: entry.value # Trim points preceding days for point, i in points if point.day is days[0] points.splice(0, i) break # Ensure points for each day for day, i in days if points.length <= i or points[i].day isnt day prevY = if i > 0 then points[i - 1].y else 0.0 points.splice i, 0, y: prevY day: day points[i].x = i points.splice(0, points.length - days.length) if points.length > days.length points createLineCharts: -> d3Utils.createLineChart('.kpi-recent-chart', @kpiRecentChartLines) d3Utils.createLineChart('.kpi-chart', @kpiChartLines) d3Utils.createLineChart('.active-classes-chart', @activeClassesChartLines) d3Utils.createLineChart('.active-users-chart', @activeUsersChartLines) d3Utils.createLineChart('.recurring-revenue-chart', @revenueChartLines) updateAllKPIChartData: -> @kpiRecentChartLines = [] @kpiChartLines = [] @updateKPIChartData(60, @kpiRecentChartLines) @updateKPIChartData(300, @kpiChartLines) updateKPIChartData: (timeframeDays, chartLines) -> days = d3Utils.createContiguousDays(timeframeDays) if @activeClasses?.length > 0 data = [] for entry in @activeClasses data.push day: entry.day value: entry.groups[entry.groups.length - 1] data.reverse() points = @createLineChartPoints(days, data) chartLines.push points: points description: '30-day Active Classes' lineColor: 'blue' strokeWidth: 1 min: 0 max: _.max(points, 'y').y showYScale: true if @revenue?.length > 0 data = [] for entry in @revenue data.push day: entry.day value: entry.groups[entry.groups.length - 1] / 100000 data.reverse() points = @createLineChartPoints(days, data) chartLines.push points: points description: '30-day Recurring Revenue (in thousands)' lineColor: 'green' strokeWidth: 1 min: 0 max: _.max(points, 'y').y showYScale: true if @activeUsers?.length > 0 data = [] for entry in @activeUsers break unless entry.monthlyCount data.push day: entry.day value: entry.monthlyCount / 1000 data.reverse() points = @createLineChartPoints(days, data) chartLines.push points: points description: '30-day Active Users (in thousands)' lineColor: 'red' strokeWidth: 1 min: 0 max: _.max(points, 'y').y showYScale: true updateActiveClassesChartData: -> @activeClassesChartLines = [] return unless @activeClasses?.length days = d3Utils.createContiguousDays(90) groupDayMap = {} for entry in @activeClasses for count, i in entry.groups groupDayMap[@activeClassGroups[i]] ?= {} groupDayMap[@activeClassGroups[i]][entry.day] ?= 0 groupDayMap[@activeClassGroups[i]][entry.day] += count lines = [] colorIndex = 0 totalMax = 0 for group, entries of groupDayMap data = [] for day, count of entries data.push day: day value: count data.reverse() points = @createLineChartPoints(days, data) @activeClassesChartLines.push points: points description: group.replace('Active classes ', '') lineColor: @lineColors[colorIndex++ % @lineColors.length] strokeWidth: 1 min: 0 showYScale: group is 'Total' totalMax = _.max(points, 'y').y if group is 'Total' line.max = totalMax for line in @activeClassesChartLines updateActiveUsersChartData: -> @activeUsersChartLines = [] return unless @activeUsers?.length days = d3Utils.createContiguousDays(90) dailyData = [] monthlyData = [] dausmausData = [] colorIndex = 0 for entry in @activeUsers dailyData.push day: entry.day value: entry.dailyCount / 1000 if entry.monthlyCount monthlyData.push day: entry.day value: entry.monthlyCount / 1000 dausmausData.push day: entry.day value: Math.round(entry.dailyCount / entry.monthlyCount * 100) dailyData.reverse() monthlyData.reverse() dausmausData.reverse() dailyPoints = @createLineChartPoints(days, dailyData) monthlyPoints = @createLineChartPoints(days, monthlyData) dausmausPoints = @createLineChartPoints(days, dausmausData) @activeUsersChartLines.push points: dailyPoints description: 'Daily active users (in thousands)' lineColor: @lineColors[colorIndex++ % @lineColors.length] strokeWidth: 1 min: 0 max: _.max(dailyPoints, 'y').y showYScale: true @activeUsersChartLines.push points: monthlyPoints description: 'Monthly active users (in thousands)' lineColor: @lineColors[colorIndex++ % @lineColors.length] strokeWidth: 1 min: 0 max: _.max(monthlyPoints, 'y').y showYScale: true @activeUsersChartLines.push points: dausmausPoints description: 'DAUs/MAUs %' lineColor: @lineColors[colorIndex++ % @lineColors.length] strokeWidth: 1 min: 0 max: _.max(dausmausPoints, 'y').y showYScale: true updateRevenueChartData: -> @revenueChartLines = [] return unless @revenue?.length days = d3Utils.createContiguousDays(90) groupDayMap = {} for entry in @revenue for count, i in entry.groups groupDayMap[@revenueGroups[i]] ?= {} groupDayMap[@revenueGroups[i]][entry.day] ?= 0 groupDayMap[@revenueGroups[i]][entry.day] += count lines = [] colorIndex = 0 dailyMax = 0 for group, entries of groupDayMap data = [] for day, count of entries data.push day: day value: count / 100 data.reverse() points = @createLineChartPoints(days, data) @revenueChartLines.push points: points description: group.replace('DRR ', '') lineColor: @lineColors[colorIndex++ % @lineColors.length] strokeWidth: 1 min: 0 max: _.max(points, 'y').y showYScale: group in ['Daily', 'Monthly'] dailyMax = _.max(points, 'y').y if group is 'Daily' for line in @revenueChartLines when line.description isnt 'Monthly' line.max = dailyMax