mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-14 09:41:35 -05:00
395 lines
13 KiB
CoffeeScript
395 lines
13 KiB
CoffeeScript
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
|