Add trials to active classes analytics dashboard

Only looking at active classrooms, split into paid, trial, and free.
Active class: 12+ students total, 6+ active in last 30 days.
This commit is contained in:
Matt Lott 2016-02-04 16:31:47 -08:00
parent 95e627f346
commit e8c22679d9
3 changed files with 108 additions and 257 deletions

View file

@ -27,6 +27,7 @@ block content
.kpi-chart.line-chart-container .kpi-chart.line-chart-container
h3 Active Classes 90 days h3 Active Classes 90 days
.small Active class: 12+ students in a classroom, with 6+ who played in last 30 days.
.active-classes-chart.line-chart-container .active-classes-chart.line-chart-container
h3 Recurring Revenue 90 days h3 Recurring Revenue 90 days

View file

@ -1,31 +1,42 @@
/* global db */
/* global Mongo */
/* global ISODate */
// Insert per-day active class counts into analytics.perdays collection // Insert per-day active class counts into analytics.perdays collection
// Usage: // Usage:
// mongo <address>:<port>/<database> <script file> -u <username> -p <password> // mongo <address>:<port>/<database> <script file> -u <username> -p <password>
try { try {
logDB = new Mongo("localhost").getDB("analytics") var logDB = new Mongo("localhost").getDB("analytics")
var scriptStartTime = new Date(); var scriptStartTime = new Date();
var analyticsStringCache = {}; var analyticsStringCache = {};
var minClassSize = 12;
var minActiveCount = 6;
var eventNamePaid = 'Active classes paid';
var eventNameTrial = 'Active classes trial';
var eventNameFree = 'Active classes free';
var numDays = 40; var numDays = 40;
var daysInMonth = 30; var daysInMonth = 30;
var startDay = new Date(); var startDay = new Date();
today = startDay.toISOString().substr(0, 10); var today = startDay.toISOString().substr(0, 10);
startDay.setUTCDate(startDay.getUTCDate() - numDays); startDay.setUTCDate(startDay.getUTCDate() - numDays);
startDay = startDay.toISOString().substr(0, 10); startDay = startDay.toISOString().substr(0, 10);
log("Today is " + today); log("Today is " + today);
log("Start day is " + startDay); log("Start day is " + startDay);
log("Getting active class counts..."); log("Getting active class counts..");
var activeClassCounts = getActiveClassCounts(startDay); var activeClassCounts = getActiveClassCounts(startDay);
// printjson(activeClassCounts); // printjson(activeClassCounts);
log("Inserting active class counts..."); log("Inserting active class counts..");
for (var event in activeClassCounts) { for (var event in activeClassCounts) {
for (var day in activeClassCounts[event]) { for (var day in activeClassCounts[event]) {
if (today === day) continue; // Never save data for today because it's incomplete if (today === day) continue; // Never save data for today because it's incomplete
// print(event, day, activeClassCounts[event][day]);
insertEventCount(event, day, activeClassCounts[event][day]); insertEventCount(event, day, activeClassCounts[event][day]);
} }
} }
@ -38,130 +49,73 @@ catch(err) {
} }
function getActiveClassCounts(startDay) { function getActiveClassCounts(startDay) {
// Tally active classes per day // Tally active classes per day, for paid, trial, and free
// TODO: does not handle class membership changes // TODO: does not handle class membership changes
if (!startDay) return {}; if (!startDay) return {};
var minGroupSize = 12; var cursor, doc;
var classes = {
'Active classes private clan': [],
'Active classes managed subscription': [],
'Active classes bulk subscription': [],
'Active classes prepaid': [],
'Active classes course free': [],
'Active classes course paid': []
};
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
bulkSubGroups = {};
cursor = db.prepaids.find(
{$and: [{type: 'terminal_subscription'}, {$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());
}
classes['Active classes prepaid'].push({
owner: owner,
members: members,
activeDayMap: {}
});
}
// Classrooms // Classrooms
var classroomCourseInstancesMap = {}; // paid: at least one paid member
cursor = db.course.instances.find( // free trial: not paid, at least one trial member
{$where: 'this.members && this.members.length >= ' + minGroupSize}, // free: not paid, not free trial
{classroomID: 1, courseID: 1, members: 1, ownerID: 1} // user.coursePrepaidID set means access to paid courses
); // prepaid.properties.trialRequestID means access was via trial
// Find classroom users
log("Finding classrooms..");
var userClassroomsMap = {};
var classroomUsersMap = {};
var classroomUserIDs = [];
var classroomUserObjectIds = [];
cursor = db.classrooms.find({}, {members: 1});
while (cursor.hasNext()) { while (cursor.hasNext()) {
var doc = cursor.next(); doc = cursor.next();
var owner = doc.ownerID.valueOf(); if (doc.members) {
var classroom = doc.classroomID ? doc.classroomID.valueOf() : doc._id.valueOf(); var classroomID = doc._id.valueOf();
var members = []; for (var i = 0; i < doc.members.length; i++) {
for (var i = 0 ; i < doc.members.length; i++) { if (doc.members.length < minClassSize) continue;
userPlayedMap[doc.members[i].valueOf()] = []; var userID = doc.members[i].valueOf();
members.push(doc.members[i].valueOf()); if (!userClassroomsMap[userID]) userClassroomsMap[userID] = [];
userClassroomsMap[userID].push(classroomID);
if (!classroomUsersMap[classroomID]) classroomUsersMap[classroomID] = [];
classroomUsersMap[classroomID].push(userID)
classroomUserIDs.push(doc.members[i].valueOf());
classroomUserObjectIds.push(doc.members[i]);
}
} }
if (!classroomCourseInstancesMap[classroom]) classroomCourseInstancesMap[classroom] = [];
classroomCourseInstancesMap[classroom].push({
course: doc.courseID.valueOf(),
owner: owner,
members: members,
});
} }
// printjson(classroomCourseInstancesMap); log("Find user types..");
var userEventMap = {};
var prepaidUsersMap = {};
var prepaidIDs = [];
cursor = db.users.find({_id: {$in: classroomUserObjectIds}}, {coursePrepaidID: 1});
while (cursor.hasNext()) {
doc = cursor.next();
if (doc.coursePrepaidID) {
userEventMap[doc._id.valueOf()] = eventNamePaid;
if (!prepaidUsersMap[doc.coursePrepaidID.valueOf()]) prepaidUsersMap[doc.coursePrepaidID.valueOf()] = [];
prepaidUsersMap[doc.coursePrepaidID.valueOf()].push(doc._id.valueOf());
prepaidIDs.push(doc.coursePrepaidID);
}
else {
userEventMap[doc._id.valueOf()] = eventNameFree;
}
}
cursor = db.prepaids.find({_id: {$in: prepaidIDs}}, {properties: 1});
while (cursor.hasNext()) {
doc = cursor.next();
if (doc.properties && doc.properties.trialRequestID) {
for (var i = 0; i < prepaidUsersMap[doc._id.valueOf()].length; i++) {
userEventMap[prepaidUsersMap[doc._id.valueOf()][i]] = eventNameTrial;
}
}
}
// Find all the started level events for our class members, for startDay - daysInMonth log("Find Started Level log events for all classroom members for last " + (numDays + daysInMonth) + " days..");
var userPlayedMap = {};
var startDate = ISODate(startDay + "T00:00:00.000Z"); var startDate = ISODate(startDay + "T00:00:00.000Z");
startDate.setUTCDate(startDate.getUTCDate() - daysInMonth); startDate.setUTCDate(startDate.getUTCDate() - daysInMonth);
var endDate = ISODate(startDay + "T00:00:00.000Z"); var endDate = ISODate(startDay + "T00:00:00.000Z");
@ -169,162 +123,72 @@ function getActiveClassCounts(startDay) {
var startObj = objectIdWithTimestamp(startDate); var startObj = objectIdWithTimestamp(startDate);
var queryParams = {$and: [ var queryParams = {$and: [
{_id: {$gte: startObj}}, {_id: {$gte: startObj}},
{user: {$in: Object.keys(userPlayedMap)}}, {user: {$in: classroomUserIDs}},
{event: 'Started Level'} {event: 'Started Level'}
]}; ]};
cursor = logDB['log'].find(queryParams, {user: 1}); cursor = logDB['log'].find(queryParams, {user: 1});
while (cursor.hasNext()) { while (cursor.hasNext()) {
var doc = cursor.next(); doc = cursor.next();
if (!userPlayedMap[doc.user]) userPlayedMap[doc.user] = [];
userPlayedMap[doc.user].push(doc._id.getTimestamp()); userPlayedMap[doc.user].push(doc._id.getTimestamp());
} }
// printjson(userPlayedMap); // 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 log("Calculate number of active members per classroom per day per event type..");
for (var event in classes) { var classDayTypeMap = {};
for (var i = 0; i < classes[event].length; i++) { for (var classroom in classroomUsersMap) {
if (classroomUsersMap[classroom].length < minClassSize) continue;
// 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);
}
// Classrooms are processed differently because they could be free or paid active classes
var courseNameMap = {};
cursor = db.courses.find({}, {name: 1});
while (cursor.hasNext()) {
var doc = cursor.next();
courseNameMap[doc._id.valueOf()] = doc.name;
}
// For each classroom, check free and paid members separately
for (var classroom in classroomCourseInstancesMap) {
var freeMembers = {};
var paidMembers = {};
var owner = null;
for (var i = 0; i < classroomCourseInstancesMap[classroom].length; i++) {
var courseInstance = classroomCourseInstancesMap[classroom][i];
if (!owner) owner = courseInstance.owner;
for (var j = 0; j < courseInstance.members.length; j++) {
if (courseNameMap[courseInstance.course] === 'Introduction to Computer Science') {
freeMembers[courseInstance.members[j]] = true;
}
else {
paidMembers[courseInstance.members[j]] = true;
}
}
}
var freeClass = {
owner: owner,
members: Object.keys(freeMembers),
activeDayMap: {}
};
var paidClass = {
owner: owner,
members: Object.keys(paidMembers),
activeDayMap: {}
};
// print('Processing classroom', classroom, freeClass.members.length, paidClass.members.length);
// For each each day in our target date range
classDayTypeMap[classroom] = {};
startDate = ISODate(startDay + "T00:00:00.000Z"); startDate = ISODate(startDay + "T00:00:00.000Z");
startDate.setUTCDate(startDate.getUTCDate() - daysInMonth); startDate.setUTCDate(startDate.getUTCDate() - daysInMonth);
endDate = ISODate(startDay + "T00:00:00.000Z"); endDate = ISODate(startDay + "T00:00:00.000Z");
while (endDate < todayDate) { while (endDate < todayDate) {
var endDay = endDate.toISOString().substring(0, 10); var endDay = endDate.toISOString().substring(0, 10);
classDayTypeMap[classroom][endDay] = {};
classDayTypeMap[classroom][endDay][eventNamePaid] = 0;
classDayTypeMap[classroom][endDay][eventNameTrial] = 0;
classDayTypeMap[classroom][endDay][eventNameFree] = 0;
// For each paid member of current class // Count active users of each type for current day
var paidActiveMemberCount = 0; for (var j = 0; j < classroomUsersMap[classroom].length; j++) {
for (var j = 0; j < paidClass.members.length; j++) { var member = classroomUsersMap[classroom][j];
var member = paidClass.members[j];
// Was member active during current timeframe? // Was member active during current timeframe?
if (userPlayedMap[member]) { if (userPlayedMap[member]) {
for (var k = 0; k < userPlayedMap[member].length; k++) { for (var k = 0; k < userPlayedMap[member].length; k++) {
if (userPlayedMap[member][k] > startDate && userPlayedMap[member][k] <= endDate) { if (userPlayedMap[member][k] > startDate && userPlayedMap[member][k] <= endDate) {
paidActiveMemberCount++; classDayTypeMap[classroom][endDay][userEventMap[member]]++;
break; break;
} }
} }
} }
} }
// Classes active for a given day if has minGroupSize members, and at least 1/2 played in last daysInMonth days
if (paidClass.members.length > minGroupSize && paidActiveMemberCount >= Math.round(paidClass.members.length / 2)) {
// print('paid classroom', classroom, endDay);
paidClass.activeDayMap[endDay] = true;
}
else {
// For each free member of current class
var freeActiveMemberCount = 0;
for (var j = 0; j < freeClass.members.length; j++) {
var member = freeClass.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) {
freeActiveMemberCount++;
break;
}
}
}
}
if (freeClass.members.length > minGroupSize && freeActiveMemberCount >= Math.round(freeClass.members.length / 2)) {
// print('free classroom', classroom, endDay);
freeClass.activeDayMap[endDay] = true;
}
}
startDate.setUTCDate(startDate.getUTCDate() + 1); startDate.setUTCDate(startDate.getUTCDate() + 1);
endDate.setUTCDate(endDate.getUTCDate() + 1); endDate.setUTCDate(endDate.getUTCDate() + 1);
} }
// printjson(freeClass);
// printjson(paidClass);
classes['Active classes course free'].push(freeClass);
classes['Active classes course paid'].push(paidClass);
} }
// printjson(classes['Active classes course paid']); log("Aggregate class counts by day and type..");
var activeClassCounts = {}; var activeClassCounts = {};
for (var event in classes) { for (var classroom in classDayTypeMap) {
if (!activeClassCounts[event]) activeClassCounts[event] = {}; for (var endDay in classDayTypeMap[classroom]) {
for (var i = 0; i < classes[event].length; i++) { var activeStudents = 0;
for (var endDay in classes[event][i].activeDayMap) { var classEvent = eventNameFree;
if (!activeClassCounts[event][endDay]) activeClassCounts[event][endDay] = 0; for (var event in classDayTypeMap[classroom][endDay]) {
activeClassCounts[event][endDay]++; if (classDayTypeMap[classroom][endDay][event] > 1) {
activeStudents += classDayTypeMap[classroom][endDay][event];
if (event === eventNamePaid) classEvent = event;
if (classEvent !== eventNamePaid && event === eventNameTrial) classEvent = event;
}
}
if (activeStudents >= minActiveCount) {
if (!activeClassCounts[classEvent]) activeClassCounts[classEvent] = {};
if (!activeClassCounts[classEvent][endDay]) activeClassCounts[classEvent][endDay] = 0;
activeClassCounts[classEvent][endDay]++;
} }
} }
} }
@ -334,17 +198,6 @@ function getActiveClassCounts(startDay) {
// *** Helper functions *** // *** Helper functions ***
function slugify(text)
// https://gist.github.com/mathewbyrne/1280286
{
return text.toString().toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
.replace(/\-\-+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, ''); // Trim - from end of text
}
function log(str) { function log(str) {
print(new Date().toISOString() + " " + str); print(new Date().toISOString() + " " + str);
} }

View file

@ -26,12 +26,9 @@ class AnalyticsPerDayHandler extends Handler
getActiveClasses: (req, res) -> getActiveClasses: (req, res) ->
events = [ events = [
'Active classes private clan', 'Active classes paid',
'Active classes managed subscription', 'Active classes trial',
'Active classes bulk subscription', 'Active classes free'
'Active classes prepaid',
'Active classes course free',
'Active classes course paid'
] ]
AnalyticsString.find({v: {$in: events}}).exec (err, documents) => AnalyticsString.find({v: {$in: events}}).exec (err, documents) =>