From 7861faaf9366b7127a46cbff4fba3b9df4ad0e12 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Fri, 6 Nov 2015 14:11:36 -0800 Subject: [PATCH] Add active classes to admin analytics page https://app.asana.com/0/54276215890539/59638739614287 --- app/styles/admin/analytics.sass | 2 + app/templates/admin/analytics.jade | 16 ++ app/views/admin/AnalyticsView.coffee | 38 +++- .../mongodb/queries/insertPerDayAnalytics.js | 183 ++++++++++++++++++ .../analytics/analytics_perday_handler.coffee | 29 +++ 5 files changed, 265 insertions(+), 3 deletions(-) diff --git a/app/styles/admin/analytics.sass b/app/styles/admin/analytics.sass index 34e27eff1..ee6e17085 100644 --- a/app/styles/admin/analytics.sass +++ b/app/styles/admin/analytics.sass @@ -4,6 +4,8 @@ width: 100% .big-stat width: auto + .active-classes + color: blue .active-users color: green .count diff --git a/app/templates/admin/analytics.jade b/app/templates/admin/analytics.jade index 56b70066a..d92d55bfc 100644 --- a/app/templates/admin/analytics.jade +++ b/app/templates/admin/analytics.jade @@ -5,11 +5,27 @@ block content if me.isAdmin() .container-fluid .row + .col-md-5.big-stat.active-classes + if activeClasses.length > 0 + div.description 30-day Active Classes + div.count= activeClasses[0].groups[activeClasses[0].groups.length - 1] .col-md-5.big-stat.active-users if activeUsers.length > 0 div.description 30-day Active Users div.count= activeUsers[0].monthlyCount + h1 Active Classes + table.table.table-striped.table-condensed + tr + th Day + for group in activeClassGroups + th= group.replace('Active classes', '') + each activeClass in activeClasses + tr + td= activeClass.day + each val in activeClass.groups + td= val + h1 Active Users table.table.table-striped.table-condensed tr diff --git a/app/views/admin/AnalyticsView.coffee b/app/views/admin/AnalyticsView.coffee index 7fac38ac6..612b521ce 100644 --- a/app/views/admin/AnalyticsView.coffee +++ b/app/views/admin/AnalyticsView.coffee @@ -8,9 +8,11 @@ module.exports = class AnalyticsView extends RootView constructor: (options) -> super options + startDay = utils.getUTCDay(-30).replace(/-/g, '') endDay = utils.getUTCDay(-30).replace(/-/g, '') - request = @supermodel.addRequestResource 'active_users', { + + @supermodel.addRequestResource('active_users', { url: '/db/analytics_perday/-/active_users' data: {startDay: startDay, endDay: endDay} method: 'POST' @@ -18,10 +20,40 @@ module.exports = class AnalyticsView extends RootView @activeUsers = data @activeUsers.sort (a, b) -> b.day.localeCompare(a.day) @render?() - }, 0 - request.load() + }, 0).load() + + @supermodel.addRequestResource('active_classes', { + url: '/db/analytics_perday/-/active_classes' + data: {startDay: startDay, endDay: endDay} + method: 'POST' + success: (data) => + @activeClassGroups = {} + dayEventsMap = {} + for activeClass in data + dayEventsMap[activeClass.day] ?= {} + dayEventsMap[activeClass.day]['Total'] = 0 + for event, val of activeClass.classes + @activeClassGroups[event] = true + dayEventsMap[activeClass.day][event] = val + dayEventsMap[activeClass.day]['Total'] += val + @activeClassGroups = Object.keys(@activeClassGroups) + @activeClassGroups.push 'Total' + for day of dayEventsMap + for event in @activeClassGroups + dayEventsMap[day][event] ?= 0 + @activeClasses = [] + for day of dayEventsMap + data = day: day, groups: [] + for group in @activeClassGroups + data.groups.push(dayEventsMap[day][group] ? 0) + @activeClasses.push data + @activeClasses.sort (a, b) -> b.day.localeCompare(a.day) + @render?() + }, 0).load() getRenderData: -> context = super() + context.activeClasses = @activeClasses ? [] + context.activeClassGroups = @activeClassGroups ? {} context.activeUsers = @activeUsers ? [] context diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js index f7afe6664..c6dd6217a 100644 --- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js +++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js @@ -90,6 +90,17 @@ try { } } + log("Getting active class counts..."); + var activeClassCounts = getActiveClassCounts(startDay); + // printjson(activeClassCounts); + log("Inserting active class counts..."); + for (var event in activeClassCounts) { + for (var day in activeClassCounts[event]) { + if (today === day) continue; // Never save data for today because it's incomplete + insertEventCount(event, day, activeClassCounts[event][day]); + } + } + log("Script runtime: " + (new Date() - scriptStartTime)); } catch(err) { @@ -440,6 +451,178 @@ function getActiveUserCounts(startDay, activeUserEvents) { return activeUsersCounts; } +function getActiveClassCounts(startDay) { + // Tally active classes per day + // TODO: does not handle class membership changes + + if (!startDay) return {}; + + var minGroupSize = 12; + var classes = { + 'Active classes private clan': [], + 'Active classes managed subscription': [], + 'Active classes bulk subscription': [], + 'Active classes prepaid': [], + 'Active classes course': [], + }; + var userPlayedMap = {}; + + // Private clans + // TODO: does not handle clan membership changes over time + var cursor = db.clans.find({$and: [{type: 'private'}, {$where: 'this.members.length >= ' + minGroupSize}]}); + while (cursor.hasNext()) { + var doc = cursor.next(); + var members = doc.members.map(function(a) { + userPlayedMap[a.valueOf()] = []; + return a.valueOf(); + }); + classes['Active classes private clan'].push({ + owner: doc.ownerID.valueOf(), + members: members, + activeDayMap: {} + }); + } + + // Managed subscriptions + // TODO: does not handle former recipients playing after sponsorship ends + var bulkSubGroups = {}; + cursor = db.payments.find({$and: [{service: 'stripe'}, {$where: '!this.purchaser.equals(this.recipient)'}]}); + while (cursor.hasNext()) { + var doc = cursor.next(); + var purchaser = doc.purchaser.valueOf(); + if (!bulkSubGroups[purchaser]) bulkSubGroups[purchaser] = {}; + bulkSubGroups[purchaser][doc.recipient.valueOf()] = true; + } + for (var purchaser in bulkSubGroups) { + if (Object.keys(bulkSubGroups[purchaser]).length >= minGroupSize) { + for (var member in bulkSubGroups[purchaser]) { + userPlayedMap[member] = []; + } + classes['Active classes managed subscription'].push({ + owner: purchaser, + members: Object.keys(bulkSubGroups[purchaser]), + activeDayMap: {} + }); + } + } + + // Bulk subscriptions + bulkSubGroups = {}; + cursor = db.payments.find({$and: [{service: 'external'}, {$where: '!this.purchaser.equals(this.recipient)'}]}); + while (cursor.hasNext()) { + var doc = cursor.next(); + var purchaser = doc.purchaser.valueOf(); + if (!bulkSubGroups[purchaser]) bulkSubGroups[purchaser] = {}; + bulkSubGroups[purchaser][doc.recipient.valueOf()] = true; + } + for (var purchaser in bulkSubGroups) { + if (Object.keys(bulkSubGroups[purchaser]).length >= minGroupSize) { + for (var member in bulkSubGroups[purchaser]) { + userPlayedMap[member] = []; + } + classes['Active classes bulk subscription'].push({ + owner: purchaser, + members: Object.keys(bulkSubGroups[purchaser]), + activeDayMap: {} + }); + } + } + + // Prepaids terminal_subscription & course + bulkSubGroups = {}; + cursor = db.prepaids.find( + {$and: [{type: {$in: ['terminal_subscription', 'course']}}, {$where: 'this.redeemers && this.redeemers.length >= ' + minGroupSize}]}, + {creator: 1, type: 1, redeemers: 1} + ); + while (cursor.hasNext()) { + var doc = cursor.next(); + var owner = doc.creator.valueOf(); + var members = []; + for (var i = 0 ; i < doc.redeemers.length; i++) { + userPlayedMap[doc.redeemers[i].userID.valueOf()] = []; + members.push(doc.redeemers[i].userID.valueOf()); + } + var event = doc.type == 'terminal_subscription' ? 'Active classes prepaid' : 'Active classes course'; + classes[event].push({ + owner: owner, + members: members, + activeDayMap: {} + }); + } + + // printjson(classes); + + // TODO: classrooms + + // Find all the started level events for our class members, for startDay - daysInMonth + var startDate = ISODate(startDay + "T00:00:00.000Z"); + startDate.setUTCDate(startDate.getUTCDate() - daysInMonth); + var endDate = ISODate(startDay + "T00:00:00.000Z"); + var todayDate = new Date(new Date().toISOString().substring(0, 10)); + var startObj = objectIdWithTimestamp(startDate); + var queryParams = {$and: [ + {_id: {$gte: startObj}}, + {event: 'Started Level'}, + {user: {$in: Object.keys(userPlayedMap)}} + ]}; + cursor = logDB['log'].find(queryParams, {user: 1}); + // cursor = db['level.sessions'].find({$and: [{creator: {$in: Object.keys(userPlayedMap)}}, {changed: {$gte: startDate}}]}, {creator: 1, changed: 1}); + while (cursor.hasNext()) { + var doc = cursor.next(); + userPlayedMap[doc.user].push(doc._id.getTimestamp()); + } + + // printjson(userPlayedMap); + // print(startDate, endDate, todayDate); + + // Now we have a set of classes, and when users played + // For a given day, walk classes and find out how many members were active during the previous daysInMonth + while (endDate < todayDate) { + var endDay = endDate.toISOString().substring(0, 10); + + // For each class + for (var event in classes) { + for (var i = 0; i < classes[event].length; i++) { + + // For each member of current class + var activeMemberCount = 0; + for (var j = 0; j < classes[event][i].members.length; j++) { + var member = classes[event][i].members[j]; + + // Was member active during current timeframe? + if (userPlayedMap[member]) { + for (var k = 0; k < userPlayedMap[member].length; k++) { + if (userPlayedMap[member][k] > startDate && userPlayedMap[member][k] <= endDate) { + activeMemberCount++; + break; + } + } + } + } + + // Classes active for a given day if has minGroupSize members, and at least 1/2 played in last daysInMonth days + if (activeMemberCount >= Math.round(classes[event][i].members.length / 2)) { + classes[event][i].activeDayMap[endDay] = true; + } + } + } + startDate.setUTCDate(startDate.getUTCDate() + 1); + endDate.setUTCDate(endDate.getUTCDate() + 1); + } + + var activeClassCounts = {}; + for (var event in classes) { + if (!activeClassCounts[event]) activeClassCounts[event] = {}; + for (var i = 0; i < classes[event].length; i++) { + for (var endDay in classes[event][i].activeDayMap) { + if (!activeClassCounts[event][endDay]) activeClassCounts[event][endDay] = 0; + activeClassCounts[event][endDay]++; + } + } + } + return activeClassCounts; +} + function insertEventCount(event, day, count) { // analytics.perdays schema in server/analytics/AnalyticsPeryDay.coffee day = day.replace(/-/g, ''); diff --git a/server/analytics/analytics_perday_handler.coffee b/server/analytics/analytics_perday_handler.coffee index 79d713cee..859f11a2a 100644 --- a/server/analytics/analytics_perday_handler.coffee +++ b/server/analytics/analytics_perday_handler.coffee @@ -14,6 +14,7 @@ class AnalyticsPerDayHandler extends Handler getByRelationship: (req, res, args...) -> return @sendForbiddenError res unless @hasAccess req + return @getActiveClasses(req, res) if args[1] is 'active_classes' return @getActiveUsers(req, res) if args[1] is 'active_users' return @getCampaignCompletionsBySlug(req, res) if args[1] is 'campaign_completions' return @getLevelCompletionsBySlug(req, res) if args[1] is 'level_completions' @@ -22,6 +23,34 @@ class AnalyticsPerDayHandler extends Handler return @getLevelSubscriptionsBySlugs(req, res) if args[1] is 'level_subscriptions' super(arguments...) + getActiveClasses: (req, res) -> + events = [ + 'Active classes private clan', + 'Active classes managed subscription', + 'Active classes bulk subscription', + 'Active classes prepaid', + 'Active classes course'] + + AnalyticsString.find({v: {$in: events}}).exec (err, documents) => + return @sendDatabaseError(res, err) if err + eventIDs = [] + eventStringMap = {} + for doc in documents + eventStringMap[doc._id.valueOf()] = doc.v + eventIDs.push doc._id + return @sendSuccess res, [] unless eventIDs.length is events.length + + AnalyticsPerDay.find({e: {$in: eventIDs}}).exec (err, documents) => + return @sendDatabaseError(res, err) if err + dayCountsMap = {} + for doc in documents + dayCountsMap[doc.d] ?= {} + dayCountsMap[doc.d][eventStringMap[doc.e.valueOf()]] = doc.c + activeClasses = [] + for key, val of dayCountsMap + activeClasses.push day: key, classes: dayCountsMap[key] + @sendSuccess(res, activeClasses) + getActiveUsers: (req, res) -> AnalyticsString.find({v: {$in: ['Daily Active Users', 'Monthly Active Users']}}).exec (err, documents) => return @sendDatabaseError(res, err) if err