Update campaign editor analytics

Adding shown and purchased subscription counts.
Locking down analytics to admin-only.
This commit is contained in:
Matt Lott 2015-01-21 13:41:34 -08:00
parent 6b271799be
commit e49c74259b
7 changed files with 327 additions and 96 deletions

View file

@ -1,8 +1,16 @@
#campaign-analytics-modal
td
font-size: 9pt
max-width: 60px
td.completion-rate
max-width: 1000px
td.level
max-width: 1000px
.modal-dialog
width: 75%
width: 85%
.level-name-container
position: relative
max-width: 1000px
.level-name-background
position: absolute
height: 100%
@ -12,6 +20,7 @@
opacity: 0.25
.level-completion-container
position: relative
max-width: 1000px
.level-completion-background
position: absolute
height: 100%

View file

@ -7,21 +7,32 @@ block modal-header-content
input.form-control#input-startday(type='text', style='width:100px;', value=campaignCompletions.startDay)
input.form-control#input-endday(type='text', style='width:100px;', value=campaignCompletions.endDay)
button.btn.btn-default.btn-sm#reload-button(style='margin-left:10px;') Reload
div(style='font-size:10px') Double-click row to open level details.
label(style='font-size:10px;font-weight:normal;')
span Double-click row to open level details.
span   
input#option-show-left-game(type='checkbox', checked=showLeftGame)
span Show Left Game
span   
input#option-show-subscriptions(type='checkbox', checked=showSubscriptions)
span Show Subscriptions
block modal-body-content
if campaignCompletions && campaignCompletions.levels
table.table.table-bordered.table-condensed.table-hover(style='font-size:10pt')
thead
tr
td Level
td.level Level
td Started
td Finished
td.completion-rate Completion %
td Playtime (s)
if showLeftGame
td Left Game
td LG %
td Playtime (s)
td LG/s
td Completion %
if showSubscriptions
td Sub Shown
td Sub Purchased
tbody
- for (var i = 0; i < campaignCompletions.levels.length; i++)
tr.level(data-level-slug=campaignCompletions.levels[i].level)
@ -29,26 +40,6 @@ block modal-body-content
span.level-name-background(style="width:#{campaignCompletions.levels[i].usersRemaining || 0}%;")
td= campaignCompletions.levels[i].started
td= campaignCompletions.levels[i].finished
td= campaignCompletions.levels[i].dropped
if campaignCompletions.levels[i].dropPercentage
if campaignCompletions.top3DropPercentage && campaignCompletions.top3DropPercentage.indexOf(campaignCompletions.levels[i].level) >= 0
td(style='background-color:pink;')= campaignCompletions.levels[i].dropPercentage.toFixed(2)
else
td= campaignCompletions.levels[i].dropPercentage.toFixed(2)
else
td
if campaignCompletions.levels[i].averagePlaytime
td.level-playtime-container= campaignCompletions.levels[i].averagePlaytime.toFixed(2)
span.level-playtime-background(style="width:#{campaignCompletions.levels[i].playtimePercentage || 0}%;")
else
td
if campaignCompletions.levels[i].droppedPerSecond
if campaignCompletions.top3DropPerSecond && campaignCompletions.top3DropPerSecond.indexOf(campaignCompletions.levels[i].level) >= 0
td(style='background-color:pink;')= campaignCompletions.levels[i].droppedPerSecond.toFixed(2)
else
td= campaignCompletions.levels[i].droppedPerSecond.toFixed(2)
else
td
if campaignCompletions.levels[i].completionRate
if campaignCompletions.top3 && campaignCompletions.top3.indexOf(campaignCompletions.levels[i].level) >= 0
td.level-completion-container(style='background-color:lightblue;')= campaignCompletions.levels[i].completionRate.toFixed(2)
@ -59,8 +50,32 @@ block modal-body-content
else
td.level-completion-container= campaignCompletions.levels[i].completionRate.toFixed(2)
svg.level-completion-background(id="background#{campaignCompletions.levels[i].level}")
else
td.completion-rate
if campaignCompletions.levels[i].averagePlaytime
td.level-playtime-container= campaignCompletions.levels[i].averagePlaytime.toFixed(2)
span.level-playtime-background(style="width:#{campaignCompletions.levels[i].playtimePercentage || 0}%;")
else
td
if showLeftGame
td= campaignCompletions.levels[i].dropped
if campaignCompletions.levels[i].dropPercentage
if campaignCompletions.top3DropPercentage && campaignCompletions.top3DropPercentage.indexOf(campaignCompletions.levels[i].level) >= 0
td(style='background-color:pink;')= campaignCompletions.levels[i].dropPercentage.toFixed(2)
else
td= campaignCompletions.levels[i].dropPercentage.toFixed(2)
else
td
if campaignCompletions.levels[i].droppedPerSecond
if campaignCompletions.top3DropPerSecond && campaignCompletions.top3DropPerSecond.indexOf(campaignCompletions.levels[i].level) >= 0
td(style='background-color:pink;')= campaignCompletions.levels[i].droppedPerSecond.toFixed(2)
else
td= campaignCompletions.levels[i].droppedPerSecond.toFixed(2)
else
td
if showSubscriptions
td= campaignCompletions.levels[i].subsShown
td= campaignCompletions.levels[i].subsPurchased
else
div Loading...

View file

@ -16,6 +16,7 @@ block header
span.glyphicon-home.glyphicon
ul.nav.navbar-nav.navbar-right
if me.isAdmin()
li#analytics-button
a
span.glyphicon-stats.glyphicon

View file

@ -14,14 +14,20 @@ module.exports = class CampaignAnalyticsModal extends ModalView
events:
'click #reload-button': 'onClickReloadButton'
'dblclick .level': 'onDblClickLevel'
'change #option-show-left-game': 'updateShowLeftGame'
'change #option-show-subscriptions': 'updateShowSubscriptions'
constructor: (options, @campaignHandle, @campaignCompletions) ->
super options
@getCampaignAnalytics() unless @campaignCompletions?.levels?
@showLeftGame = true
@showSubscriptions = true
@getCampaignAnalytics() if me.isAdmin()
getRenderData: ->
c = super()
c.campaignCompletions = @campaignCompletions
c.showLeftGame = @showLeftGame
c.showSubscriptions = @showSubscriptions
c
afterRender: ->
@ -30,6 +36,14 @@ module.exports = class CampaignAnalyticsModal extends ModalView
$("#input-endday").datepicker dateFormat: "yy-mm-dd"
@addCompletionLineGraphs()
updateShowLeftGame: ->
@showLeftGame = @$el.find('#option-show-left-game').prop('checked')
@render()
updateShowSubscriptions: ->
@showSubscriptions = @$el.find('#option-show-subscriptions').prop('checked')
@render()
onClickReloadButton: () =>
startDay = $('#input-startday').val()
endDay = $('#input-endday').val()
@ -45,6 +59,7 @@ module.exports = class CampaignAnalyticsModal extends ModalView
@hide()
addCompletionLineGraphs: ->
# TODO: no line graphs if some levels without completion rates?
return unless @campaignCompletions.levels
for level in @campaignCompletions.levels
days = []
@ -114,12 +129,14 @@ module.exports = class CampaignAnalyticsModal extends ModalView
@render()
@getCampaignAveragePlaytimes startDayDashed, endDayDashed, () =>
@render()
@getCampaignLevelSubscriptions startDay, endDay, () =>
@render()
getCampaignAveragePlaytimes: (startDay, endDay, doneCallback) =>
# Fetch level average playtimes
# Needs date format yyyy-mm-dd
success = (data) =>
return if @destroyed
return doneCallback() if @destroyed
# console.log 'getCampaignAveragePlaytimes success', data
levelAverages = {}
maxPlaytime = 0
@ -161,7 +178,7 @@ module.exports = class CampaignAnalyticsModal extends ModalView
getCampaignLevelCompletions: (startDay, endDay, doneCallback) =>
# Needs date format yyyymmdd
success = (data) =>
return if @destroyed
return doneCallback() if @destroyed
# console.log 'getCampaignLevelCompletions success', data
countCompletions = (item) ->
item.started = _.reduce item.days, ((result, current) -> result + current.started), 0
@ -217,7 +234,7 @@ module.exports = class CampaignAnalyticsModal extends ModalView
@campaignCompletions.top3DropPercentage = _.pluck sortedLevels[0..2], 'level'
doneCallback()
return unless @campaignCompletions?.levels?
return doneCallback() unless @campaignCompletions?.levels?
levelSlugs = _.pluck @campaignCompletions.levels, 'level'
request = @supermodel.addRequestResource 'level_drops', {
@ -227,3 +244,28 @@ module.exports = class CampaignAnalyticsModal extends ModalView
success: success
}, 0
request.load()
getCampaignLevelSubscriptions: (startDay, endDay, doneCallback) =>
# Fetch level subscriptions
# Needs date format yyyymmdd
success = (data) =>
return doneCallback() if @destroyed
# console.log 'getCampaignLevelSubscriptions success', data
levelSubs = {}
for item in data
levelSubs[item.level] = shown: item.shown, purchased: item.purchased
for level in @campaignCompletions.levels
level.subsShown = levelSubs[level.level]?.shown
level.subsPurchased = levelSubs[level.level]?.purchased
doneCallback()
return doneCallback() unless @campaignCompletions?.levels?
levelSlugs = _.pluck @campaignCompletions.levels, 'level'
request = @supermodel.addRequestResource 'campaign_subscriptions', {
url: '/db/analytics_perday/-/level_subscriptions'
data: {startDay: startDay, endDay: endDay, slugs: levelSlugs}
method: 'POST'
success: success
}, 0
request.load()

View file

@ -16,11 +16,81 @@
// TODO: Are Mixpanel rates accounting for finishing steps likely to be completed in the future?
// TODO: Use Mixpanel export API to investigate
// TODO: Output documents updated/inserted
try {
var scriptStartTime = new Date();
var analyticsStringCache = {};
// Look at last 30 days, same as Mixpanel
var numDays = 30;
var startDay = new Date();
today = startDay.toISOString().substr(0, 10);
startDay.setUTCDate(startDay.getUTCDate() - numDays);
startDay = startDay.toISOString().substr(0, 10);
var levelCompletionFunnel = ['Started Level', 'Saw Victory'];
var levelHelpEvents = ['Problem alert help clicked', 'Spell palette help clicked', 'Start help video'];
log("Today is " + today);
log("Start day is " + startDay);
log("Funnel events are " + levelCompletionFunnel);
log("Getting level completion data...");
var levelCompletionData = getLevelFunnelData(startDay, levelCompletionFunnel);
log("Inserting aggregated level completion data...");
for (level in levelCompletionData) {
for (day in levelCompletionData[level]) {
if (today === day) continue; // Never save data for today because it's incomplete
for (event in levelCompletionData[level][day]) {
insertEventCount(event, level, day, levelCompletionData[level][day][event]);
}
}
}
log("Getting level drop counts...");
var levelDropCounts = getLevelDropCounts(startDay, levelCompletionFunnel);
log("Inserting level drop counts...");
for (level in levelDropCounts) {
for (day in levelDropCounts[level]) {
if (today === day) continue; // Never save data for today because it's incomplete
insertEventCount('User Dropped', level, day, levelDropCounts[level][day]);
}
}
log("Getting level help counts...");
var levelHelpCounts = getLevelHelpCounts(startDay, levelHelpEvents);
log("Inserting level help counts...");
for (level in levelHelpCounts) {
for (day in levelHelpCounts[level]) {
if (today === day) continue; // Never save data for today because it's incomplete
for (event in levelHelpCounts[level][day]) {
insertEventCount(event, level, day, levelHelpCounts[level][day][event]);
}
}
}
log("Getting level subscription counts...");
var levelSubscriptionCounts = getLevelSubscriptionCounts(startDay);
log("Inserting level subscription counts...");
for (level in levelSubscriptionCounts) {
for (day in levelSubscriptionCounts[level]) {
if (today === day) continue; // Never save data for today because it's incomplete
for (event in levelSubscriptionCounts[level][day]) {
insertEventCount(event, level, day, levelSubscriptionCounts[level][day][event]);
}
}
}
log("Script runtime: " + (new Date() - scriptStartTime));
}
catch(err) {
log("ERROR: " + err);
printjson(err);
}
// *** Helper functions ***
function log(str) {
print(new Date().toISOString() + " " + str);
}
@ -229,6 +299,82 @@ function getLevelHelpCounts(startDay, events) {
return levelEventData;
}
function getLevelSubscriptionCounts(startDay) {
// Counts subscriptions shown per day, only for events that have levels
// Subscription purchased event counts are attributed to last shown subscription modal event's day and level
if (!startDay) return {};
var startObj = objectIdWithTimestamp(ISODate(startDay + "T00:00:00.000Z"));
var queryParams = {$and: [
{_id: {$gte: startObj}},
{$or: [
{$and: [{'event': 'Show subscription modal'}, {'properties.level': {$exists: true}}]},
{'event': 'Finished subscription purchase'}]
}
]};
var cursor = db['analytics.log.events'].find(queryParams);
// Map ordering: user, event, level, day
// Map ordering: user, event, day
var userDataMap = {};
while (cursor.hasNext()) {
var doc = cursor.next();
var created = doc._id.getTimestamp().toISOString();
var day = created.substring(0, 10);
var event = doc.event;
var user = doc.user;
if (!userDataMap[user]) userDataMap[user] = {};
if (event === 'Show subscription modal') {
var level = doc.properties.level;
// TODO: This is for legacy data.
// TODO: Event tracking updated to use level slug for loading level view on ~1/21/15
level = level.toLowerCase().replace(/ /g, '-');
if (!userDataMap[user][event]) userDataMap[user][event] = {};
if (!userDataMap[user][event][level] || userDataMap[user][event][level].localeCompare(day) > 0) {
userDataMap[user][event][level] = day;
}
}
else if (event === 'Finished subscription purchase') {
if (!userDataMap[user][event] || userDataMap[user][event].localeCompare(day) > 0) {
userDataMap[user][event] = day;
}
} else {
continue;
}
}
// Data: level, day, event
var levelFunnelData = {};
for (user in userDataMap) {
if (userDataMap[user]['Show subscription modal']) {
var lastDay = null;
var lastLevel = null;
for (level in userDataMap[user]['Show subscription modal']) {
var day = userDataMap[user]['Show subscription modal'][level];
if (!lastDay || lastDay.localeCompare(day) > 0) {
lastDay = day;
lastLevel = level;
}
if (!levelFunnelData[level]) levelFunnelData[level] = {};
if (!levelFunnelData[level][day]) levelFunnelData[level][day] = {};
if (!levelFunnelData[level][day][event]) levelFunnelData[level][day]['Show subscription modal'] = 0;
levelFunnelData[level][day]['Show subscription modal']++;
}
if (lastDay && userDataMap[user]['Finished subscription purchase']) {
if (!levelFunnelData[lastLevel][lastDay]['Finished subscription purchase']) {
levelFunnelData[lastLevel][lastDay]['Finished subscription purchase'] = 0;
}
levelFunnelData[lastLevel][lastDay]['Finished subscription purchase']++;
}
}
}
return levelFunnelData;
}
function insertEventCount(event, level, day, count) {
// analytics.perdays schema in server/analytics/AnalyticsPeryDay.coffee
day = day.replace(/-/g, '');
@ -264,60 +410,3 @@ function insertEventCount(event, level, day, count) {
// }
}
}
try {
// Look at last 30 days, same as Mixpanel
var numDays = 30;
var startDay = new Date();
today = startDay.toISOString().substr(0, 10);
startDay.setUTCDate(startDay.getUTCDate() - numDays);
startDay = startDay.toISOString().substr(0, 10);
var levelCompletionFunnel = ['Started Level', 'Saw Victory'];
var levelHelpEvents = ['Problem alert help clicked', 'Spell palette help clicked', 'Start help video'];
log("Today is " + today);
log("Start day is " + startDay);
log("Funnel events are " + levelCompletionFunnel);
log("Getting level completion data...");
var levelCompletionData = getLevelFunnelData(startDay, levelCompletionFunnel);
log("Inserting aggregated level completion data...");
for (level in levelCompletionData) {
for (day in levelCompletionData[level]) {
if (today === day) continue; // Never save data for today because it's incomplete
for (event in levelCompletionData[level][day]) {
insertEventCount(event, level, day, levelCompletionData[level][day][event]);
}
}
}
log("Getting level drop counts...");
var levelDropCounts = getLevelDropCounts(startDay, levelCompletionFunnel)
log("Inserting level drop counts...");
for (level in levelDropCounts) {
for (day in levelDropCounts[level]) {
if (today === day) continue; // Never save data for today because it's incomplete
insertEventCount('User Dropped', level, day, levelDropCounts[level][day]);
}
}
log("Getting level help counts...");
var levelHelpCounts = getLevelHelpCounts(startDay, levelHelpEvents)
log("Inserting level help counts...");
for (level in levelHelpCounts) {
for (day in levelHelpCounts[level]) {
if (today === day) continue; // Never save data for today because it's incomplete
for (event in levelHelpCounts[level][day]) {
insertEventCount(event, level, day, levelHelpCounts[level][day][event]);
}
}
}
}
catch(err) {
log("ERROR: " + err);
printjson(err);
}
log("Script runtime: " + (new Date() - scriptStartTime));

View file

@ -10,13 +10,15 @@ class AnalyticsPerDayHandler extends Handler
jsonSchema: require '../../app/schemas/models/analytics_perday'
hasAccess: (req) ->
req.method in ['GET'] or req.user?.isAdmin()
req.user?.isAdmin() or false
getByRelationship: (req, res, args...) ->
return @sendForbiddenError res unless @hasAccess req
return @getCampaignCompletionsBySlug(req, res) if args[1] is 'campaign_completions'
return @getLevelCompletionsBySlug(req, res) if args[1] is 'level_completions'
return @getLevelDropsBySlugs(req, res) if args[1] is 'level_drops'
return @getLevelHelpsBySlugs(req, res) if args[1] is 'level_helps'
return @getLevelSubscriptionsBySlugs(req, res) if args[1] is 'level_subscriptions'
super(arguments...)
getCampaignCompletionsBySlug: (req, res) ->
@ -334,4 +336,73 @@ class AnalyticsPerDayHandler extends Handler
@levelHelpsCache[cacheKey] = helps
@sendSuccess res, helps
getLevelSubscriptionsBySlugs: (req, res) ->
# Send back an array of level subscriptions shown and purchased counts
# Parameters:
# slugs - level slugs
# startDay - Inclusive, optional, YYYYMMDD e.g. '20141214'
# endDay - Exclusive, optional, YYYYMMDD e.g. '20141216'
levelSlugs = req.query.slugs or req.body.slugs
startDay = req.query.startDay or req.body.startDay
endDay = req.query.endDay or req.body.endDay
# log.warn "level_subscriptions levelSlugs='#{levelSlugs}' startDay=#{startDay} endDay=#{endDay}"
return @sendSuccess res, [] unless levelSlugs?
# Cache results in app server memory for 1 day
@levelSubscriptionsCache ?= {}
@levelSubscriptionsCachedSince ?= new Date()
if (new Date()) - @levelSubscriptionsCachedSince > 86400 * 1000
@levelSubscriptionsCache = {}
@levelSubscriptionsCachedSince = new Date()
cacheKey = levelSlugs.join ''
cacheKey += 's' + startDay if startDay?
cacheKey += 'e' + endDay if endDay?
return @sendSuccess res, subscriptions if subscriptions = @levelSubscriptionsCache[cacheKey]
findQueryParams = {v: {$in: ['Show subscription modal', 'Finished subscription purchase', 'all'].concat(levelSlugs)}}
AnalyticsString.find(findQueryParams).exec (err, documents) =>
if err? then return @sendDatabaseError res, err
levelStringIDSlugMap = {}
for doc in documents
showSubEventID = doc._id if doc.v is 'Show subscription modal'
finishSubEventID = doc._id if doc.v is 'Finished subscription purchase'
filterEventID = doc._id if doc.v is 'all'
levelStringIDSlugMap[doc._id] = doc.v if doc.v in levelSlugs
return @sendSuccess res, [] unless showSubEventID? and finishSubEventID? and filterEventID?
queryParams = {$and: [
{e: {$in: [showSubEventID, finishSubEventID]}},
{f: filterEventID},
{l: {$in: Object.keys(levelStringIDSlugMap)}}
]}
queryParams["$and"].push {d: {$gte: startDay}} if startDay?
queryParams["$and"].push {d: {$lt: endDay}} if endDay?
AnalyticsPerDay.find(queryParams).exec (err, documents) =>
if err? then return @sendDatabaseError res, err
levelEventCounts = {}
for doc in documents
levelEventCounts[doc.l] ?= {}
levelEventCounts[doc.l][doc.e] ?= 0
levelEventCounts[doc.l][doc.e] += doc.c
subscriptions = []
for levelID of levelEventCounts
for eventID of levelEventCounts[levelID]
subsShown = levelEventCounts[levelID][eventID] if parseInt(eventID) is showSubEventID
subsPurchased = levelEventCounts[levelID][eventID] if parseInt(eventID) is finishSubEventID
subscriptions.push
level: levelStringIDSlugMap[levelID]
shown: subsShown ? 0
purchased: subsPurchased ? 0
@levelSubscriptionsCache[cacheKey] = subscriptions
@sendSuccess res, subscriptions
module.exports = new AnalyticsPerDayHandler()

View file

@ -24,7 +24,11 @@ class UserCodeProblemHandler extends Handler
ucp.set('creator', req.user._id)
ucp
hasAccess: (req) ->
req.user?.isAdmin() or false
getByRelationship: (req, res, args...) ->
return @sendForbiddenError res unless @hasAccess req
return @getCommonLevelProblemsBySlug(req, res) if args[1] is 'common_problems'
super(arguments...)