mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2025-05-02 00:43:34 -04:00
Add campaign drop-offs analytics to editor
This commit is contained in:
parent
0e9f1e0370
commit
e65887ec79
5 changed files with 281 additions and 5 deletions
app
styles/editor/campaign
templates/editor/campaign
views/editor/campaign
server
|
@ -21,3 +21,12 @@
|
||||||
bottom: 0
|
bottom: 0
|
||||||
right: 0
|
right: 0
|
||||||
width: 75%
|
width: 75%
|
||||||
|
|
||||||
|
#analytics-button
|
||||||
|
position: absolute
|
||||||
|
right: 1%
|
||||||
|
top: 1%
|
||||||
|
padding: 3px 8px
|
||||||
|
|
||||||
|
#analytics-modal .modal-content
|
||||||
|
background-color: white
|
||||||
|
|
|
@ -40,5 +40,40 @@ block outer_content
|
||||||
#right-column
|
#right-column
|
||||||
#campaign-view
|
#campaign-view
|
||||||
#campaign-level-view.hidden
|
#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
|
block footer
|
||||||
|
|
|
@ -47,6 +47,8 @@ module.exports = class CampaignEditorView extends RootView
|
||||||
@listenToOnce @levels, 'sync', @onFundamentalLoaded
|
@listenToOnce @levels, 'sync', @onFundamentalLoaded
|
||||||
@listenToOnce @achievements, 'sync', @onFundamentalLoaded
|
@listenToOnce @achievements, 'sync', @onFundamentalLoaded
|
||||||
|
|
||||||
|
_.delay @getCampaignDropOffs, 1000
|
||||||
|
|
||||||
loadThangTypeNames: ->
|
loadThangTypeNames: ->
|
||||||
# Load the names of the ThangTypes that this level's Treema nodes might want to display.
|
# Load the names of the ThangTypes that this level's Treema nodes might want to display.
|
||||||
originals = []
|
originals = []
|
||||||
|
@ -130,6 +132,7 @@ module.exports = class CampaignEditorView extends RootView
|
||||||
getRenderData: ->
|
getRenderData: ->
|
||||||
c = super()
|
c = super()
|
||||||
c.campaign = @campaign
|
c.campaign = @campaign
|
||||||
|
c.campaignDropOffs = @campaignDropOffs
|
||||||
c
|
c
|
||||||
|
|
||||||
onClickSaveButton: ->
|
onClickSaveButton: ->
|
||||||
|
@ -236,6 +239,35 @@ module.exports = class CampaignEditorView extends RootView
|
||||||
achievement.set 'rewards', newRewards
|
achievement.set 'rewards', newRewards
|
||||||
if achievement.hasLocalChanges()
|
if achievement.hasLocalChanges()
|
||||||
@toSave.add achievement
|
@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
|
class LevelsNode extends TreemaObjectNode
|
||||||
valueClass: 'treema-levels'
|
valueClass: 'treema-levels'
|
||||||
|
|
|
@ -20,6 +20,7 @@ class AnalyticsLogEventHandler extends Handler
|
||||||
|
|
||||||
getByRelationship: (req, res, args...) ->
|
getByRelationship: (req, res, args...) ->
|
||||||
return @getLevelCompletionsBySlugs(req, res) if args[1] is 'level_completions'
|
return @getLevelCompletionsBySlugs(req, res) if args[1] is 'level_completions'
|
||||||
|
return @getCampaignDropOffs(req, res) if args[1] is 'campaign_drop_offs'
|
||||||
super(arguments...)
|
super(arguments...)
|
||||||
|
|
||||||
getLevelCompletionsBySlugs: (req, res) ->
|
getLevelCompletionsBySlugs: (req, res) ->
|
||||||
|
@ -29,7 +30,7 @@ class AnalyticsLogEventHandler extends Handler
|
||||||
# startDay - Inclusive, optional, e.g. '2014-12-14'
|
# startDay - Inclusive, optional, e.g. '2014-12-14'
|
||||||
# endDay - Exclusive, optional, e.g. '2014-12-16'
|
# 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
|
levelSlugs = req.query.slugs or req.body.slugs
|
||||||
startDay = req.query.startDay or req.body.startDay
|
startDay = req.query.startDay or req.body.startDay
|
||||||
|
@ -42,7 +43,7 @@ class AnalyticsLogEventHandler extends Handler
|
||||||
@levelCompletionsCachedSince ?= new Date()
|
@levelCompletionsCachedSince ?= new Date()
|
||||||
if (new Date()) - @levelCompletionsCachedSince > 86400 * 1000 # Dumb cache expiration
|
if (new Date()) - @levelCompletionsCachedSince > 86400 * 1000 # Dumb cache expiration
|
||||||
@levelCompletionsCache = {}
|
@levelCompletionsCache = {}
|
||||||
@levelCompletionsCacheSince = new Date()
|
@levelCompletionsCachedSince = new Date()
|
||||||
cacheKey = levelSlugs.join(',')
|
cacheKey = levelSlugs.join(',')
|
||||||
cacheKey += 's' + startDay if startDay?
|
cacheKey += 's' + startDay if startDay?
|
||||||
cacheKey += 'e' + endDay if endDay?
|
cacheKey += 'e' + endDay if endDay?
|
||||||
|
@ -90,4 +91,203 @@ class AnalyticsLogEventHandler extends Handler
|
||||||
@levelCompletionsCache[cacheKey] = completions
|
@levelCompletionsCache[cacheKey] = completions
|
||||||
@sendSuccess res, 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()
|
module.exports = new AnalyticsLogEventHandler()
|
||||||
|
|
|
@ -322,7 +322,7 @@ LevelHandler = class LevelHandler extends Handler
|
||||||
@playCountCachedSince ?= new Date()
|
@playCountCachedSince ?= new Date()
|
||||||
if (new Date()) - @playCountCachedSince > 86400 * 1000 # Dumb cache expiration
|
if (new Date()) - @playCountCachedSince > 86400 * 1000 # Dumb cache expiration
|
||||||
@playCountCache = {}
|
@playCountCache = {}
|
||||||
@playCountCacheSince = new Date()
|
@playCountCachedSince = new Date()
|
||||||
cacheKey = levelIDs.join ','
|
cacheKey = levelIDs.join ','
|
||||||
if playCounts = @playCountCache[cacheKey]
|
if playCounts = @playCountCache[cacheKey]
|
||||||
return @sendSuccess res, playCounts
|
return @sendSuccess res, playCounts
|
||||||
|
@ -349,7 +349,7 @@ LevelHandler = class LevelHandler extends Handler
|
||||||
# startDay - Inclusive, optional, e.g. '2014-12-14'
|
# startDay - Inclusive, optional, e.g. '2014-12-14'
|
||||||
# endDay - Exclusive, optional, e.g. '2014-12-16'
|
# 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?
|
# TODO: This is very similar to getLevelCompletionsBySlugs(), time to generalize analytics APIs?
|
||||||
|
|
||||||
levelSlugs = req.query.slugs or req.body.slugs
|
levelSlugs = req.query.slugs or req.body.slugs
|
||||||
|
@ -363,7 +363,7 @@ LevelHandler = class LevelHandler extends Handler
|
||||||
@levelPlaytimesCachedSince ?= new Date()
|
@levelPlaytimesCachedSince ?= new Date()
|
||||||
if (new Date()) - @levelPlaytimesCachedSince > 86400 * 1000 # Dumb cache expiration
|
if (new Date()) - @levelPlaytimesCachedSince > 86400 * 1000 # Dumb cache expiration
|
||||||
@levelPlaytimesCache = {}
|
@levelPlaytimesCache = {}
|
||||||
@levelPlaytimesCacheSince = new Date()
|
@levelPlaytimesCachedSince = new Date()
|
||||||
cacheKey = levelSlugs.join(',')
|
cacheKey = levelSlugs.join(',')
|
||||||
cacheKey += 's' + startDay if startDay?
|
cacheKey += 's' + startDay if startDay?
|
||||||
cacheKey += 'e' + endDay if endDay?
|
cacheKey += 'e' + endDay if endDay?
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue