diff --git a/app/styles/editor/campaign/campaign-analytics-modal.sass b/app/styles/editor/campaign/campaign-analytics-modal.sass index 40d19a78d..b3596890e 100644 --- a/app/styles/editor/campaign/campaign-analytics-modal.sass +++ b/app/styles/editor/campaign/campaign-analytics-modal.sass @@ -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% diff --git a/app/templates/editor/campaign/campaign-analytics-modal.jade b/app/templates/editor/campaign/campaign-analytics-modal.jade index 01b5e01b5..c2050199e 100644 --- a/app/templates/editor/campaign/campaign-analytics-modal.jade +++ b/app/templates/editor/campaign/campaign-analytics-modal.jade @@ -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 Left Game - td LG % + td.completion-rate Completion % td Playtime (s) - td LG/s - td Completion % + if showLeftGame + td Left Game + td LG % + td LG/s + 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... diff --git a/app/templates/editor/campaign/campaign-editor-view.jade b/app/templates/editor/campaign/campaign-editor-view.jade index b97154825..b503647b2 100644 --- a/app/templates/editor/campaign/campaign-editor-view.jade +++ b/app/templates/editor/campaign/campaign-editor-view.jade @@ -16,9 +16,10 @@ block header span.glyphicon-home.glyphicon ul.nav.navbar-nav.navbar-right - li#analytics-button - a - span.glyphicon-stats.glyphicon + if me.isAdmin() + li#analytics-button + a + span.glyphicon-stats.glyphicon if me.isAdmin() li#save-button a diff --git a/app/views/editor/campaign/CampaignAnalyticsModal.coffee b/app/views/editor/campaign/CampaignAnalyticsModal.coffee index d10b22f8d..5356b308d 100644 --- a/app/views/editor/campaign/CampaignAnalyticsModal.coffee +++ b/app/views/editor/campaign/CampaignAnalyticsModal.coffee @@ -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() diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js index 20ab3c854..dcc26cd8a 100644 --- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js +++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js @@ -16,10 +16,80 @@ // 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 = {}; -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)); \ No newline at end of file diff --git a/server/analytics/analytics_perday_handler.coffee b/server/analytics/analytics_perday_handler.coffee index e5e4a8e31..e64f6b7bf 100644 --- a/server/analytics/analytics_perday_handler.coffee +++ b/server/analytics/analytics_perday_handler.coffee @@ -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() diff --git a/server/user_code_problems/user_code_problem_handler.coffee b/server/user_code_problems/user_code_problem_handler.coffee index 6c293f727..9ce2cbe7e 100644 --- a/server/user_code_problems/user_code_problem_handler.coffee +++ b/server/user_code_problems/user_code_problem_handler.coffee @@ -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...)