diff --git a/app/styles/editor/campaign/campaign-editor-view.sass b/app/styles/editor/campaign/campaign-editor-view.sass index 8ce962a8d..d55eda872 100644 --- a/app/styles/editor/campaign/campaign-editor-view.sass +++ b/app/styles/editor/campaign/campaign-editor-view.sass @@ -21,3 +21,12 @@ bottom: 0 right: 0 width: 75% + + #analytics-button + position: absolute + right: 1% + top: 1% + padding: 3px 8px + + #analytics-modal .modal-content + background-color: white diff --git a/app/templates/editor/campaign/campaign-editor-view.jade b/app/templates/editor/campaign/campaign-editor-view.jade index 2515ed484..aa2d23855 100644 --- a/app/templates/editor/campaign/campaign-editor-view.jade +++ b/app/templates/editor/campaign/campaign-editor-view.jade @@ -40,5 +40,40 @@ block outer_content #right-column #campaign-view #campaign-level-view.hidden + if campaignDropOffs + button.btn.btn-default#analytics-button(title="Analytics", data-toggle="modal" data-target="#analytics-modal") Analytics + .modal.fade#analytics-modal(tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true") + .modal-dialog + .modal-content + .modal-header + button.close(type="button", data-dismiss="modal", aria-label="Close") + span(aria-hidden="true") × + h4.modal-title Analytics + .modal-body + if campaignDropOffs.startDay + if campaignDropOffs.endDay + div #{campaignDropOffs.startDay} to #{campaignDropOffs.endDay} + else + div #{campaignDropOffs.startDay} to today + table.table.table-bordered.table-condensed.table-hover(style='font-size:10pt') + thead + tr + td Level + td Started + td Dropped + td Drop % + td Finished + td Dropped + td Drop % + tbody + - for (var i = 0; i < campaignDropOffs.levels.length; i++) + tr + td= campaignDropOffs.levels[i].level + td= campaignDropOffs.levels[i].started + td= campaignDropOffs.levels[i].startDropped + td= campaignDropOffs.levels[i].startDropRate + td= campaignDropOffs.levels[i].finished + td= campaignDropOffs.levels[i].finishDropped + td= campaignDropOffs.levels[i].finishDropRate block footer diff --git a/app/views/editor/campaign/CampaignEditorView.coffee b/app/views/editor/campaign/CampaignEditorView.coffee index 2253d0cb2..4e857851b 100644 --- a/app/views/editor/campaign/CampaignEditorView.coffee +++ b/app/views/editor/campaign/CampaignEditorView.coffee @@ -47,6 +47,8 @@ module.exports = class CampaignEditorView extends RootView @listenToOnce @levels, 'sync', @onFundamentalLoaded @listenToOnce @achievements, 'sync', @onFundamentalLoaded + _.delay @getCampaignDropOffs, 1000 + loadThangTypeNames: -> # Load the names of the ThangTypes that this level's Treema nodes might want to display. originals = [] @@ -130,6 +132,7 @@ module.exports = class CampaignEditorView extends RootView getRenderData: -> c = super() c.campaign = @campaign + c.campaignDropOffs = @campaignDropOffs c onClickSaveButton: -> @@ -236,6 +239,35 @@ module.exports = class CampaignEditorView extends RootView achievement.set 'rewards', newRewards if achievement.hasLocalChanges() @toSave.add achievement + + getCampaignDropOffs: => + # Fetch last 7 days of campaign drop-off rates + + startDay = new Date() + startDay.setDate(startDay.getUTCDate() - 6) + startDay = startDay.getUTCFullYear() + '-' + (startDay.getUTCMonth() + 1) + '-' + startDay.getUTCDate() + + success = (data) => + return if @destroyed + # API returns all the campaign data currently + @campaignDropOffs = data[@campaignHandle] + mapFn = (item) -> + item.startDropRate = (item.startDropped / item.started * 100).toFixed(2) + item.finishDropRate = (item.finishDropped / item.finished * 100).toFixed(2) + item + @campaignDropOffs.levels = _.map @campaignDropOffs.levels, mapFn, @ + @campaignDropOffs.startDay = startDay + @render() + + # TODO: Why do we need this url dash? + request = @supermodel.addRequestResource 'campaign_drop_offs', { + url: '/db/analytics_log_event/-/campaign_drop_offs' + data: {startDay: startDay, slugs: [@campaignHandle]} + method: 'POST' + success: success + }, 0 + request.load() + class LevelsNode extends TreemaObjectNode valueClass: 'treema-levels' diff --git a/server/analytics/analytics_log_event_handler.coffee b/server/analytics/analytics_log_event_handler.coffee index afdb6068b..8115a5fcf 100644 --- a/server/analytics/analytics_log_event_handler.coffee +++ b/server/analytics/analytics_log_event_handler.coffee @@ -20,6 +20,7 @@ class AnalyticsLogEventHandler extends Handler getByRelationship: (req, res, args...) -> return @getLevelCompletionsBySlugs(req, res) if args[1] is 'level_completions' + return @getCampaignDropOffs(req, res) if args[1] is 'campaign_drop_offs' super(arguments...) getLevelCompletionsBySlugs: (req, res) -> @@ -29,7 +30,7 @@ class AnalyticsLogEventHandler extends Handler # startDay - Inclusive, optional, e.g. '2014-12-14' # endDay - Exclusive, optional, e.g. '2014-12-16' - # TODO: An uncached call takes about 15s + # TODO: An uncached call takes about 15s locally levelSlugs = req.query.slugs or req.body.slugs startDay = req.query.startDay or req.body.startDay @@ -42,7 +43,7 @@ class AnalyticsLogEventHandler extends Handler @levelCompletionsCachedSince ?= new Date() if (new Date()) - @levelCompletionsCachedSince > 86400 * 1000 # Dumb cache expiration @levelCompletionsCache = {} - @levelCompletionsCacheSince = new Date() + @levelCompletionsCachedSince = new Date() cacheKey = levelSlugs.join(',') cacheKey += 's' + startDay if startDay? cacheKey += 'e' + endDay if endDay? @@ -90,4 +91,203 @@ class AnalyticsLogEventHandler extends Handler @levelCompletionsCache[cacheKey] = completions @sendSuccess res, completions + getCampaignDropOffs: (req, res) -> + # Returns a dictionary of per-campaign level start and finish drop-offs + # Drop-off: last started or finished level event + # Parameters: + # slugs - array of campaign slugs + # startDay - Inclusive, optional, e.g. '2014-12-14' + # endDay - Exclusive, optional, e.g. '2014-12-16' + + # TODO: Read per-campaign level progression data from a legit source + # TODO: An uncached call can take over 30s locally + # TODO: Returns all the campaigns + # TODO: Calculate overall campaign stats + + campaignSlugs = req.query.slugs or req.body.slugs + startDay = req.query.startDay or req.body.startDay + endDay = req.query.endDay or req.body.endDay + + return @sendSuccess res, [] unless campaignSlugs? + + # Cache results for 1 day + @campaignDropOffsCache ?= {} + @campaignDropOffsCachedSince ?= new Date() + if (new Date()) - @campaignDropOffsCachedSince > 86400 * 1000 # Dumb cache expiration + @campaignDropOffsCache = {} + @campaignDropOffsCachedSince = new Date() + cacheKey = campaignSlugs.join(',') + cacheKey += 's' + startDay if startDay? + cacheKey += 'e' + endDay if endDay? + return @sendSuccess res, campaignDropOffs if campaignDropOffs = @campaignDropOffsCache[cacheKey] + + queryParams = {$and: [{$or: [ {"event" : 'Started Level'}, {"event" : 'Saw Victory'}]}]} + queryParams["$and"].push created: {$gte: new Date(startDay + "T00:00:00.000Z")} if startDay? + queryParams["$and"].push created: {$lt: new Date(endDay + "T00:00:00.000Z")} if endDay? + + AnalyticsLogEvent.find(queryParams).select('created event properties user').exec (err, data) => + if err? then return @sendDatabaseError res, err + + # Bucketize events by user + userProgression = {} + for item in data + created = item.get('created') + event = item.get('event') + if event is 'Saw Victory' + level = item.get('properties.level').toLowerCase().replace new RegExp(' ', 'g'), '-' + else + level = item.get('properties.levelID') + continue unless level? + user = item.get('user') + userProgression[user] ?= [] + userProgression[user].push + created: created + event: event + level: level + + # Order user progression by created + for user in userProgression + userProgression[user].sort (a,b) -> if a.created < b.created then return -1 else 1 + + # Per-level start/drop/finish/drop + levelProgression = {} + for user of userProgression + for i in [0...userProgression[user].length] + event = userProgression[user][i].event + level = userProgression[user][i].level + levelProgression[level] ?= + started: 0 + startDropped: 0 + finished: 0 + finishDropped: 0 + if event is 'Started Level' + levelProgression[level].started++ + levelProgression[level].startDropped++ if i is userProgression[user].length - 1 + else if event is 'Saw Victory' + levelProgression[level].finished++ + levelProgression[level].finishDropped++ if i is userProgression[user].length - 1 + + # Put in campaign order + campaignRates = {} + for level of levelProgression + for campaign of campaigns + if level in campaigns[campaign] + started = levelProgression[level].started + startDropped = levelProgression[level].startDropped + finished = levelProgression[level].finished + finishDropped = levelProgression[level].finishDropped + campaignRates[campaign] ?= + levels: [] + # overall: + # started: 0, + # startDropped: 0, + # finished: 0, + # finishDropped: 0 + campaignRates[campaign].levels.push + level: level + started: started + startDropped: startDropped + finished: finished + finishDropped: finishDropped + break + + # Sort level data by campaign order + for campaign of campaignRates + campaignRates[campaign].levels.sort (a, b) -> + if campaigns[campaign].indexOf(a.level) < campaigns[campaign].indexOf(b.level) then return -1 else 1 + + # Return all campaign data for simplicity + # Cache other individual campaigns too, since we have them + @campaignDropOffsCache[cacheKey] = campaignRates + for campaign of campaignRates + cacheKey = campaign + cacheKey += 's' + startDay if startDay? + cacheKey += 'e' + endDay if endDay? + @campaignDropOffsCache[cacheKey] = campaignRates + @sendSuccess res, campaignRates + +# Copied from WorldMapView +dungeonLevels = [ + 'dungeons-of-kithgard', + 'gems-in-the-deep', + 'shadow-guard', + 'kounter-kithwise', + 'crawlways-of-kithgard', + 'forgetful-gemsmith', + 'true-names', + 'favorable-odds', + 'the-raised-sword', + 'haunted-kithmaze', + 'riddling-kithmaze', + 'descending-further', + 'the-second-kithmaze', + 'dread-door', + 'known-enemy', + 'master-of-names', + 'lowly-kithmen', + 'closing-the-distance', + 'tactical-strike', + 'the-final-kithmaze', + 'the-gauntlet', + 'kithgard-gates', + 'cavern-survival' +]; + +forestLevels = [ + 'defense-of-plainswood', + 'winding-trail', + 'patrol-buster', + 'endangered-burl', + 'village-guard', + 'thornbush-farm', + 'back-to-back', + 'ogre-encampment', + 'woodland-cleaver', + 'shield-rush', + 'peasant-protection', + 'munchkin-swarm', + 'munchkin-harvest', + 'swift-dagger', + 'shrapnel', + 'arcane-ally', + 'touch-of-death', + 'bonemender', + 'coinucopia', + 'copper-meadows', + 'drop-the-flag', + 'deadly-pursuit', + 'rich-forager', + 'siege-of-stonehold', + 'multiplayer-treasure-grove', + 'dueling-grounds' +]; + +desertLevels = [ + 'the-dunes', + 'the-mighty-sand-yak', + 'oasis', + 'sarven-road', + 'sarven-gaps', + 'thunderhooves', + 'medical-attention', + 'minesweeper', + 'sarven-sentry', + 'keeping-time', + 'hoarding-gold', + 'decoy-drill', + 'yakstraction', + 'sarven-brawl', + 'desert-combat', + 'dust', + 'mirage-maker', + 'sarven-savior', + 'odd-sandstorm' +]; + +campaigns = { + 'dungeon': dungeonLevels, + 'forest': forestLevels, + 'desert': desertLevels +} + module.exports = new AnalyticsLogEventHandler() diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee index 23562cbc4..03879c0c7 100644 --- a/server/levels/level_handler.coffee +++ b/server/levels/level_handler.coffee @@ -322,7 +322,7 @@ LevelHandler = class LevelHandler extends Handler @playCountCachedSince ?= new Date() if (new Date()) - @playCountCachedSince > 86400 * 1000 # Dumb cache expiration @playCountCache = {} - @playCountCacheSince = new Date() + @playCountCachedSince = new Date() cacheKey = levelIDs.join ',' if playCounts = @playCountCache[cacheKey] return @sendSuccess res, playCounts @@ -349,7 +349,7 @@ LevelHandler = class LevelHandler extends Handler # startDay - Inclusive, optional, e.g. '2014-12-14' # endDay - Exclusive, optional, e.g. '2014-12-16' - # TODO: An uncached call takes about 20s for dungeons-of-kithgard locally + # TODO: An uncached call takes about 5s for dungeons-of-kithgard locally # TODO: This is very similar to getLevelCompletionsBySlugs(), time to generalize analytics APIs? levelSlugs = req.query.slugs or req.body.slugs @@ -363,7 +363,7 @@ LevelHandler = class LevelHandler extends Handler @levelPlaytimesCachedSince ?= new Date() if (new Date()) - @levelPlaytimesCachedSince > 86400 * 1000 # Dumb cache expiration @levelPlaytimesCache = {} - @levelPlaytimesCacheSince = new Date() + @levelPlaytimesCachedSince = new Date() cacheKey = levelSlugs.join(',') cacheKey += 's' + startDay if startDay? cacheKey += 'e' + endDay if endDay?