From c4779468bd7abd4bb86a1f74958635596d061463 Mon Sep 17 00:00:00 2001
From: Josh Callebaut <josh.callebaut@gmail.com>
Date: Tue, 10 Nov 2015 13:46:48 -0800
Subject: [PATCH 1/6] Adds Josh C's image and updates the about page.

---
 app/assets/images/pages/about/josh_c_small.png | Bin 0 -> 11507 bytes
 app/templates/about.jade                       |   4 ++--
 2 files changed, 2 insertions(+), 2 deletions(-)
 create mode 100644 app/assets/images/pages/about/josh_c_small.png

diff --git a/app/assets/images/pages/about/josh_c_small.png b/app/assets/images/pages/about/josh_c_small.png
new file mode 100644
index 0000000000000000000000000000000000000000..6a04f9e2eb9aa491168489e3975ec9467c22fbe6
GIT binary patch
literal 11507
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
Z3;^2d8YFF{93lV!002ovPDHLkV1g6xVYC1M

literal 0
HcmV?d00001

diff --git a/app/templates/about.jade b/app/templates/about.jade
index 7a086a093..bf836c1b0 100644
--- a/app/templates/about.jade
+++ b/app/templates/about.jade
@@ -184,8 +184,8 @@ block content
               | Compiler Engineer
             p(data-i18n="about.rob_blurb")
               | Codes things and stuff.
-              
-          img(src="/images/pages/about/placeholder.png").img-thumbnail
+
+          img(src="/images/pages/about/josh_c_small.png").img-thumbnail
           .team_bio
             h4.team_name
               | Josh Callebaut

From e33323e7eb8a36739f637e89e159bc2b40da81a0 Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Tue, 10 Nov 2015 11:20:35 -0800
Subject: [PATCH 2/6] Add line charts to admin analytics dashboard

https://app.asana.com/0/54276215890539/64369256136957
---
 app/core/d3_utils.coffee             | 127 +++++++++
 app/styles/admin/analytics.sass      |  12 +
 app/templates/admin/analytics.jade   |  19 +-
 app/views/admin/AnalyticsView.coffee | 381 +++++++++++++++++++++------
 4 files changed, 461 insertions(+), 78 deletions(-)
 create mode 100644 app/core/d3_utils.coffee

diff --git a/app/core/d3_utils.coffee b/app/core/d3_utils.coffee
new file mode 100644
index 000000000..843d6da81
--- /dev/null
+++ b/app/core/d3_utils.coffee
@@ -0,0 +1,127 @@
+# Caller needs require 'vendor/d3'
+
+module.exports.createContiguousDays = (timeframeDays) ->
+  # Return list of last 'timeframeDays' contiguous days in yyyy-mm-dd format
+  days = []
+  currentDate = new Date()
+  currentDate.setUTCDate(currentDate.getUTCDate() - timeframeDays)
+  for i in [0..timeframeDays]
+    currentDay = currentDate.toISOString().substr(0, 10)
+    days.push(currentDay)
+    currentDate.setUTCDate(currentDate.getUTCDate() + 1)
+  days
+
+module.exports.createLineChart = (containerSelector, chartLines) ->
+  # Creates a line chart within 'containerSelector' based on chartLines
+  return unless chartLines?.length > 0 and containerSelector
+
+  margin = 20
+  keyHeight = 20
+  xAxisHeight = 20
+  yAxisWidth = 40
+  containerWidth = $(containerSelector).width()
+  containerHeight = $(containerSelector).height()
+
+  yScaleCount = 0
+  yScaleCount++ for line in chartLines when line.showYScale
+  svg = d3.select(containerSelector).append("svg")
+    .attr("width", containerWidth)
+    .attr("height", containerHeight)
+  width = containerWidth - margin * 2 - yAxisWidth * yScaleCount
+  height = containerHeight - margin * 2 - xAxisHeight - keyHeight * chartLines.length
+  currentLine = 0
+  currentYScale = 0
+
+  # Horizontal guidelines
+  marks = (Math.round(i * height / 5) for i in [1..5])
+  yRange = d3.scale.linear().range([height, 0]).domain([0, height])
+  svg.selectAll(".line")
+    .data(marks)
+    .enter()
+    .append("line")
+    .attr("x1", margin + yAxisWidth * yScaleCount)
+    .attr("y1", (d) -> margin + yRange(d))
+    .attr("x2", margin + yAxisWidth * yScaleCount + width)
+    .attr("y2", (d) -> margin + yRange(d))
+    .attr("stroke", 'gray')
+    .style("opacity", "0.3")
+
+  for line in chartLines
+    # continue unless line.enabled
+    xRange = d3.scale.linear().range([0, width]).domain([d3.min(line.points, (d) -> d.x), d3.max(line.points, (d) -> d.x)])
+    yRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max])
+
+    # x-Axis
+    if currentLine is 0
+      startDay = new Date(line.points[0].day)
+      endDay = new Date(line.points[line.points.length - 1].day)
+      xAxisRange = d3.time.scale()
+        .domain([startDay, endDay])
+        .range([0, width])
+      xAxis = d3.svg.axis()
+        .scale(xAxisRange)
+      svg.append("g")
+        .attr("class", "x axis")
+        .call(xAxis)
+        .selectAll("text")
+        .attr("dy", ".35em")
+        .attr("transform", "translate(" + (margin + yAxisWidth) + "," + (height + margin) + ")")
+        .style("text-anchor", "start")
+
+    if line.showYScale
+      # y-Axis
+      yAxisRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max])
+      yAxis = d3.svg.axis()
+        .scale(yRange)
+        .orient("left")
+      svg.append("g")
+        .attr("class", "y axis")
+        .attr("transform", "translate(" + (margin + yAxisWidth * currentYScale) + "," + margin + ")")
+        .style("color", line.lineColor)
+        .call(yAxis)
+        .selectAll("text")
+        .attr("y", 0)
+        .attr("x", 0)
+        .attr("fill", line.lineColor)
+        .style("text-anchor", "start")
+      currentYScale++
+
+    # Key
+    svg.append("line")
+      .attr("x1", margin)
+      .attr("y1", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2)
+      .attr("x2", margin + 40)
+      .attr("y2", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2)
+      .attr("stroke", line.lineColor)
+      .attr("class", "key-line")
+    svg.append("text")
+      .attr("x", margin + 40 + 10)
+      .attr("y", margin + height + xAxisHeight + keyHeight * currentLine + (keyHeight + 10) / 2)
+      .attr("fill", line.lineColor)
+      .attr("class", "key-text")
+      .text(line.description)
+
+    # Path and points
+    svg.selectAll(".circle")
+      .data(line.points)
+      .enter()
+      .append("circle")
+      .attr("transform", "translate(" + (margin + yAxisWidth * yScaleCount) + "," + margin + ")")
+      .attr("cx", (d) -> xRange(d.x))
+      .attr("cy", (d) -> yRange(d.y))
+      .attr("r", 2)
+      .attr("fill", line.lineColor)
+      .attr("stroke-width", 1)
+      .attr("class", "graph-point")
+      .attr("data-pointid", (d) -> "#{line.lineID}#{d.x}")
+    d3line = d3.svg.line()
+      .x((d) -> xRange(d.x))
+      .y((d) -> yRange(d.y))
+      .interpolate("linear")
+    svg.append("path")
+      .attr("d", d3line(line.points))
+      .attr("transform", "translate(" + (margin + yAxisWidth * yScaleCount) + "," + margin + ")")
+      .style("stroke-width", line.strokeWidth)
+      .style("stroke", line.lineColor)
+      .style("fill", "none")
+    currentLine++
diff --git a/app/styles/admin/analytics.sass b/app/styles/admin/analytics.sass
index 04f72ee5e..14df067d2 100644
--- a/app/styles/admin/analytics.sass
+++ b/app/styles/admin/analytics.sass
@@ -14,3 +14,15 @@
     font-size: 70pt
   .description
     font-size: 8pt
+
+  .line-chart-container
+    height: 500px
+    width: 100%
+    .x.axis
+      font-size: 9pt
+      path
+        display: none
+    .y.axis
+      font-size: 9pt
+      path
+        display: none
diff --git a/app/templates/admin/analytics.jade b/app/templates/admin/analytics.jade
index 6c2fc8f4a..87de02c12 100644
--- a/app/templates/admin/analytics.jade
+++ b/app/templates/admin/analytics.jade
@@ -1,7 +1,9 @@
 extends /templates/base
 
 block content
-  
+
+  //- NOTE: do not localize / i18n
+
   if me.isAdmin()
     .container-fluid
       .row
@@ -18,6 +20,21 @@ block content
             div.description 30-day Active Users
             div.count= activeUsers[0].monthlyCount
 
+    h3 KPI 60 days
+    .kpi-recent-chart.line-chart-container
+
+    h3 KPI 300 days
+    .kpi-chart.line-chart-container
+
+    h3 Active Classes 90 days
+    .active-classes-chart.line-chart-container
+
+    h3 Recurring Revenue 90 days
+    .recurring-revenue-chart.line-chart-container
+
+    h3 Active Users 90 days
+    .active-users-chart.line-chart-container
+
     h1 Active Classes
     table.table.table-striped.table-condensed
       tr
diff --git a/app/views/admin/AnalyticsView.coffee b/app/views/admin/AnalyticsView.coffee
index 390c39673..66d88a293 100644
--- a/app/views/admin/AnalyticsView.coffee
+++ b/app/views/admin/AnalyticsView.coffee
@@ -1,3 +1,5 @@
+require 'vendor/d3'
+d3Utils = require 'core/d3_utils'
 RootView = require 'views/core/RootView'
 template = require 'templates/admin/analytics'
 utils = require 'core/utils'
@@ -5,86 +7,11 @@ utils = require 'core/utils'
 module.exports = class AnalyticsView extends RootView
   id: 'admin-analytics-view'
   template: template
+  lineColors: ['red', 'blue', 'green', 'purple', 'goldenrod', 'brown', 'darkcyan']
 
   constructor: (options) ->
     super options
-
-    @supermodel.addRequestResource('active_classes', {
-      url: '/db/analytics_perday/-/active_classes'
-      method: 'POST'
-      success: (data) =>
-        @activeClassGroups = {}
-        dayEventsMap = {}
-        for activeClass in data
-          dayEventsMap[activeClass.day] ?= {}
-          dayEventsMap[activeClass.day]['Total'] = 0
-          for event, val of activeClass.classes
-            @activeClassGroups[event] = true
-            dayEventsMap[activeClass.day][event] = val
-            dayEventsMap[activeClass.day]['Total'] += val
-        @activeClassGroups = Object.keys(@activeClassGroups)
-        @activeClassGroups.push 'Total'
-        for day of dayEventsMap
-          for event in @activeClassGroups
-            dayEventsMap[day][event] ?= 0
-        @activeClasses = []
-        for day of dayEventsMap
-          data = day: day, groups: []
-          for group in @activeClassGroups
-            data.groups.push(dayEventsMap[day][group] ? 0)
-          @activeClasses.push data
-        @activeClasses.sort (a, b) -> b.day.localeCompare(a.day)
-        @render?()
-    }, 0).load()
-
-    @supermodel.addRequestResource('active_users', {
-      url: '/db/analytics_perday/-/active_users'
-      method: 'POST'
-      success: (data) =>
-        @activeUsers = data
-        @activeUsers.sort (a, b) -> b.day.localeCompare(a.day)
-        @render?()
-    }, 0).load()
-
-    @supermodel.addRequestResource('recurring_revenue', {
-      url: '/db/analytics_perday/-/recurring_revenue'
-      method: 'POST'
-      success: (data) =>
-        @revenueGroups = {}
-        dayGroupCountMap = {}
-        for dailyRevenue in data
-          dayGroupCountMap[dailyRevenue.day] ?= {}
-          dayGroupCountMap[dailyRevenue.day]['Daily'] = 0
-          for group, val of dailyRevenue.groups
-            @revenueGroups[group] = true
-            dayGroupCountMap[dailyRevenue.day][group] = val
-            dayGroupCountMap[dailyRevenue.day]['Daily'] += val
-        @revenueGroups = Object.keys(@revenueGroups)
-        @revenueGroups.push 'Daily'
-        @revenueGroups.push 'Monthly'
-        for day of dayGroupCountMap
-          for group in @revenueGroups
-            dayGroupCountMap[day][group] ?= 0
-        @revenue = []
-        for day of dayGroupCountMap
-          data = day: day, groups: []
-          for group in @revenueGroups
-            data.groups.push(dayGroupCountMap[day][group] ? 0)
-          @revenue.push data
-        @revenue.sort (a, b) -> b.day.localeCompare(a.day)
-        monthlyValues = []
-
-        return unless @revenue.length > 0
-
-        for i in [@revenue.length-1..0]
-          dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 2]
-          monthlyValues.push(dailyTotal)
-          monthlyValues.shift() if monthlyValues.length > 30
-          if monthlyValues.length is 30
-            monthlyIndex = @revenue[i].groups.length - 1
-            @revenue[i].groups[monthlyIndex] = _.reduce(monthlyValues, (s, num) -> s + num)
-        @render?()
-    }, 0).load()
+    @loadData()
 
   getRenderData: ->
     context = super()
@@ -94,3 +21,303 @@ module.exports = class AnalyticsView extends RootView
     context.revenue = @revenue ? []
     context.revenueGroups = @revenueGroups ? {}
     context
+
+  afterRender: ->
+    super()
+    @createLineCharts()
+
+  loadData: ->
+    @supermodel.addRequestResource('active_classes', {
+      url: '/db/analytics_perday/-/active_classes'
+      method: 'POST'
+      success: (data) =>
+        # Organize data by day, then group
+        groupMap = {}
+        dayGroupMap = {}
+        for activeClass in data
+          dayGroupMap[activeClass.day] ?= {}
+          dayGroupMap[activeClass.day]['Total'] = 0
+          for group, val of activeClass.classes
+            groupMap[group] = true
+            dayGroupMap[activeClass.day][group] = val
+            dayGroupMap[activeClass.day]['Total'] += val
+        @activeClassGroups = Object.keys(groupMap)
+        @activeClassGroups.push 'Total'
+        # Build list of active classes, where each entry is a day of individual group values
+        @activeClasses = []
+        for day of dayGroupMap
+          dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}"
+          data = day: dashedDay, groups: []
+          for group in @activeClassGroups
+            data.groups.push(dayGroupMap[day][group] ? 0)
+          @activeClasses.push data
+        @activeClasses.sort (a, b) -> b.day.localeCompare(a.day)
+
+        @updateAllKPIChartData()
+        @updateActiveClassesChartData()
+        @render?()
+    }, 0).load()
+
+    @supermodel.addRequestResource('active_users', {
+      url: '/db/analytics_perday/-/active_users'
+      method: 'POST'
+      success: (data) =>
+        @activeUsers = data.map (a) ->
+          a.day = "#{a.day.substring(0, 4)}-#{a.day.substring(4, 6)}-#{a.day.substring(6, 8)}"
+          a
+        @activeUsers.sort (a, b) -> b.day.localeCompare(a.day)
+
+        @updateAllKPIChartData()
+        @updateActiveUsersChartData()
+        @render?()
+    }, 0).load()
+
+    @supermodel.addRequestResource('recurring_revenue', {
+      url: '/db/analytics_perday/-/recurring_revenue'
+      method: 'POST'
+      success: (data) =>
+        # Organize data by day, then group
+        groupMap = {}
+        dayGroupCountMap = {}
+        for dailyRevenue in data
+          dayGroupCountMap[dailyRevenue.day] ?= {}
+          dayGroupCountMap[dailyRevenue.day]['Daily'] = 0
+          for group, val of dailyRevenue.groups
+            groupMap[group] = true
+            dayGroupCountMap[dailyRevenue.day][group] = val
+            dayGroupCountMap[dailyRevenue.day]['Daily'] += val
+        @revenueGroups = Object.keys(groupMap)
+        @revenueGroups.push 'Daily'
+        # Build list of recurring revenue entries, where each entry is a day of individual group values
+        @revenue = []
+        for day of dayGroupCountMap
+          dashedDay = "#{day.substring(0, 4)}-#{day.substring(4, 6)}-#{day.substring(6, 8)}"
+          data = day: dashedDay, groups: []
+          for group in @revenueGroups
+            data.groups.push(dayGroupCountMap[day][group] ? 0)
+          @revenue.push data
+        @revenue.sort (a, b) -> b.day.localeCompare(a.day)
+
+        return unless @revenue.length > 0
+
+        # Add monthly recurring revenue values
+        @revenueGroups.push 'Monthly'
+        monthlyValues = []
+        for i in [@revenue.length-1..0]
+          dailyTotal = @revenue[i].groups[@revenue[i].groups.length - 1]
+          monthlyValues.push(dailyTotal)
+          monthlyValues.shift() while monthlyValues.length > 30
+          if monthlyValues.length is 30
+            @revenue[i].groups.push(_.reduce(monthlyValues, (s, num) -> s + num))
+
+        @updateAllKPIChartData()
+        @updateRevenueChartData()
+        @render?()
+    }, 0).load()
+
+  createLineChartPoints: (days, data) ->
+    points = []
+    for entry, i in data
+      points.push
+        x: i
+        y: entry.value
+        day: entry.day
+
+    # Ensure points for each day
+    for day, i in days
+      if points.length <= i or points[i].day isnt day
+        prevY = if i > 0 then points[i - 1].y else 0.0
+        points.splice i, 0,
+          y: prevY
+          day: day
+      points[i].x = i
+
+    points.splice(0, points.length - days.length) if points.length > days.length
+    points
+
+  createLineCharts: ->
+    d3Utils.createLineChart('.kpi-recent-chart', @kpiRecentChartLines)
+    d3Utils.createLineChart('.kpi-chart', @kpiChartLines)
+    d3Utils.createLineChart('.active-classes-chart', @activeClassesChartLines)
+    d3Utils.createLineChart('.active-users-chart', @activeUsersChartLines)
+    d3Utils.createLineChart('.recurring-revenue-chart', @revenueChartLines)
+
+  updateAllKPIChartData: ->
+    @kpiRecentChartLines = []
+    @kpiChartLines = []
+    @updateKPIChartData(60, @kpiRecentChartLines)
+    @updateKPIChartData(300, @kpiChartLines)
+
+  updateKPIChartData: (timeframeDays, chartLines) ->
+    days = d3Utils.createContiguousDays(timeframeDays)
+
+    if @activeClasses?.length > 0
+      data = []
+      for entry in @activeClasses
+        data.push
+          day: entry.day
+          value: entry.groups[entry.groups.length - 1]
+      data.reverse()
+      points = @createLineChartPoints(days, data)
+      chartLines.push
+        points: points
+        description: '30-day Active Classes'
+        lineColor: 'blue'
+        strokeWidth: 1
+        min: 0
+        max: _.max(points, 'y').y
+        showYScale: true
+
+    if @revenue?.length > 0
+      data = []
+      for entry in @revenue
+        data.push
+          day: entry.day
+          value: entry.groups[entry.groups.length - 1] / 100000
+      data.reverse()
+      points = @createLineChartPoints(days, data)
+      chartLines.push
+        points: points
+        description: '30-day Recurring Revenue (in thousands)'
+        lineColor: 'green'
+        strokeWidth: 1
+        min: 0
+        max: _.max(points, 'y').y
+        showYScale: true
+
+    if @activeUsers?.length > 0
+      data = []
+      for entry in @activeUsers
+        break unless entry.monthlyCount
+        data.push
+          day: entry.day
+          value: entry.monthlyCount / 1000
+      data.reverse()
+      points = @createLineChartPoints(days, data)
+      chartLines.push
+        points: points
+        description: '30-day Active Users (in thousands)'
+        lineColor: 'red'
+        strokeWidth: 1
+        min: 0
+        max: _.max(points, 'y').y
+        showYScale: true
+
+  updateActiveClassesChartData: ->
+    @activeClassesChartLines = []
+    return unless @activeClasses?.length
+    days = d3Utils.createContiguousDays(90)
+
+    groupDayMap = {}
+    for entry in @activeClasses
+      for count, i in entry.groups
+        groupDayMap[@activeClassGroups[i]] ?= {}
+        groupDayMap[@activeClassGroups[i]][entry.day] ?= 0
+        groupDayMap[@activeClassGroups[i]][entry.day] += count
+
+    lines = []
+    colorIndex = 0
+    totalMax = 0
+    for group, entries of groupDayMap
+      data = []
+      for day, count of entries
+        data.push
+          day: day
+          value: count
+      data.reverse()
+      points = @createLineChartPoints(days, data)
+      @activeClassesChartLines.push
+        points: points
+        description: group.replace('Active classes ', '')
+        lineColor: @lineColors[colorIndex++ % @lineColors.length]
+        strokeWidth: 1
+        min: 0
+        showYScale: group is 'Total'
+      totalMax = _.max(points, 'y').y if group is 'Total'
+    line.max = totalMax for line in @activeClassesChartLines
+
+  updateActiveUsersChartData: ->
+    @activeUsersChartLines = []
+    return unless @activeUsers?.length
+    days = d3Utils.createContiguousDays(90)
+
+    dailyData = []
+    monthlyData = []
+    dausmausData = []
+    colorIndex = 0
+    for entry in @activeUsers
+      dailyData.push
+        day: entry.day
+        value: entry.dailyCount / 1000
+      if entry.monthlyCount
+        monthlyData.push
+          day: entry.day
+          value: entry.monthlyCount / 1000
+        dausmausData.push
+          day: entry.day
+          value: Math.round(entry.dailyCount / entry.monthlyCount * 100)
+    dailyData.reverse()
+    monthlyData.reverse()
+    dausmausData.reverse()
+    dailyPoints = @createLineChartPoints(days, dailyData)
+    monthlyPoints = @createLineChartPoints(days, monthlyData)
+    dausmausPoints = @createLineChartPoints(days, dausmausData)
+    @activeUsersChartLines.push
+      points: dailyPoints
+      description: 'Daily active users (in thousands)'
+      lineColor: @lineColors[colorIndex++ % @lineColors.length]
+      strokeWidth: 1
+      min: 0
+      max: _.max(dailyPoints, 'y').y
+      showYScale: true
+    @activeUsersChartLines.push
+      points: monthlyPoints
+      description: 'Monthly active users (in thousands)'
+      lineColor: @lineColors[colorIndex++ % @lineColors.length]
+      strokeWidth: 1
+      min: 0
+      max: _.max(monthlyPoints, 'y').y
+      showYScale: true
+    @activeUsersChartLines.push
+      points: dausmausPoints
+      description: 'DAUs/MAUs %'
+      lineColor: @lineColors[colorIndex++ % @lineColors.length]
+      strokeWidth: 1
+      min: 0
+      max: _.max(dausmausPoints, 'y').y
+      showYScale: true
+
+  updateRevenueChartData: ->
+    @revenueChartLines = []
+    return unless @revenue?.length
+    days = d3Utils.createContiguousDays(90)
+
+    groupDayMap = {}
+    for entry in @revenue
+      for count, i in entry.groups
+        groupDayMap[@revenueGroups[i]] ?= {}
+        groupDayMap[@revenueGroups[i]][entry.day] ?= 0
+        groupDayMap[@revenueGroups[i]][entry.day] += count
+
+    lines = []
+    colorIndex = 0
+    dailyMax = 0
+    for group, entries of groupDayMap
+      data = []
+      for day, count of entries
+        data.push
+          day: day
+          value: count / 100
+      data.reverse()
+      points = @createLineChartPoints(days, data)
+      @revenueChartLines.push
+        points: points
+        description: group.replace('DRR ', '')
+        lineColor: @lineColors[colorIndex++ % @lineColors.length]
+        strokeWidth: 1
+        min: 0
+        max: _.max(points, 'y').y
+        showYScale: group in ['Daily', 'Monthly']
+      dailyMax = _.max(points, 'y').y if group is 'Daily'
+      for line in @revenueChartLines when line.description isnt 'Monthly'
+        line.max = dailyMax

From 717377eb437dce37a0161a3f9d05a62c985689c9 Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Tue, 10 Nov 2015 14:54:39 -0800
Subject: [PATCH 3/6] Update analytics aggregation script

Giving preference to payments created field over _id date.
---
 .../analytics/mongodb/queries/insertPerDayAnalytics.js    | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js
index 913709a17..f154e4805 100644
--- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js
+++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js
@@ -642,7 +642,13 @@ function getRecurringRevenueCounts(startDay) {
   var cursor = db.payments.find({_id: {$gte: startObj}});
   while (cursor.hasNext()) {
     var doc = cursor.next();
-    var day = doc._id.getTimestamp().toISOString().substring(0, 10);
+    var day;
+    if (doc.created) {
+      day = doc.created.substring(0, 10);
+    }
+    else {
+      day = doc._id.getTimestamp().toISOString().substring(0, 10);
+    }
 
     if (doc.service === 'ios' || doc.service === 'bitcoin') continue;
 

From 4497334e6d1d52f337cb1a035ca68db78cdd00bb Mon Sep 17 00:00:00 2001
From: Nick Winter <livelily@gmail.com>
Date: Tue, 10 Nov 2015 15:21:34 -0800
Subject: [PATCH 4/6] Update a couple team avatar and descriptions

---
 README.md                | 1 +
 app/locale/en.coffee     | 4 ++--
 app/templates/about.jade | 4 ++--
 3 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 1e79276a0..3a9992acd 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ Whether you're novice or pro, the CodeCombat team is ready to help you implement
 ![Catherine Weresow](http://codecombat.com/images/pages/about/cat_small.png)
 ![Maka Gradin](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Maka%20Gradin/maka_gradin_100.png)
 ![Rob Blanckaert](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Rob%20Blanckaert/rob_blanckaert_100.png)
+![Josh Callebaut](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Josh%20Callebaut/josh_callebaut_100.png)
 ![Michael Schmatz](http://codecombat.com/images/pages/about/michael_small.png)
 ![Josh Lee](http://codecombat.com/images/pages/about/josh_small.png)
 ![Alex Cotsarelis](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Cotsarelis/alex_100.png)
diff --git a/app/locale/en.coffee b/app/locale/en.coffee
index 5b4674098..0a3f008b4 100644
--- a/app/locale/en.coffee
+++ b/app/locale/en.coffee
@@ -603,8 +603,8 @@
     rob_blurb: "Codes things and stuff"
     josh_c_title: "Game Designer"
     josh_c_blurb: "Designs games"
-    carlos_title: "Region Manager"
-    carlos_blurb: "CodeCombat Brazil"
+    carlos_title: "Region Manager, Brazil"
+    carlos_blurb: "Celery Man"
 
   teachers:
     more_info: "More Info for Teachers"
diff --git a/app/templates/about.jade b/app/templates/about.jade
index bf836c1b0..1f5a98f07 100644
--- a/app/templates/about.jade
+++ b/app/templates/about.jade
@@ -201,6 +201,6 @@ block content
             h4.team_name
               | Carlos Maia
             p(data-i18n="about.carlos_title")
-              | Region Manager
+              | Region Manager, Brazil
             p(data-i18n="about.carlos_blurb")
-              | CodeCombat Brazil
+              | Celery Man

From 22590768004f469f40bee89074c18241cccebf19 Mon Sep 17 00:00:00 2001
From: Nick Winter <livelily@gmail.com>
Date: Tue, 10 Nov 2015 15:22:09 -0800
Subject: [PATCH 5/6] LoadingScreen -> level intro behavior

---
 app/core/utils.coffee                         |  36 ++++++
 app/schemas/models/level_session.coffee       |   6 +-
 app/styles/play/level.sass                    |   8 +-
 app/styles/play/level/loading.sass            |  82 +++++++++-----
 app/styles/play/menu/guide-view.sass          |  11 ++
 app/templates/play/level/level_loading.jade   |   4 +-
 app/views/contribute/ArchmageView.coffee      |   5 +-
 app/views/play/level/LevelLoadingView.coffee  | 104 +++++++++++++++---
 app/views/play/level/PlayLevelView.coffee     |  29 ++---
 .../level/tome/SpellPaletteEntryView.coffee   |  24 +---
 app/views/play/level/tome/SpellView.coffee    |  17 +--
 app/views/play/menu/GuideView.coffee          |  21 ++--
 12 files changed, 240 insertions(+), 107 deletions(-)

diff --git a/app/core/utils.coffee b/app/core/utils.coffee
index 94cbb152b..782d487d7 100644
--- a/app/core/utils.coffee
+++ b/app/core/utils.coffee
@@ -248,3 +248,39 @@ module.exports.getPrepaidCodeAmount = getPrepaidCodeAmount = (price=999, users=0
   return 0 unless users > 0 and months > 0
   total = price * users * months
   total
+
+module.exports.filterMarkdownCodeLanguages = (text) ->
+  currentLanguage = me.get('aceConfig')?.language or 'python'
+  excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'io'], currentLanguage
+  exclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm'
+  text.replace exclusionRegex, ''
+
+module.exports.aceEditModes = aceEditModes =
+  'javascript': 'ace/mode/javascript'
+  'coffeescript': 'ace/mode/coffee'
+  'python': 'ace/mode/python'
+  'clojure': 'ace/mode/clojure'
+  'lua': 'ace/mode/lua'
+  'io': 'ace/mode/text'
+
+module.exports.initializeACE = (el, codeLanguage) ->
+  contents = $(el).text().trim()
+  editor = ace.edit el
+  editor.setOptions maxLines: Infinity
+  editor.setReadOnly true
+  editor.setTheme 'ace/theme/textmate'
+  editor.setShowPrintMargin false
+  editor.setShowFoldWidgets false
+  editor.setHighlightActiveLine false
+  editor.setHighlightActiveLine false
+  editor.setBehavioursEnabled false
+  editor.renderer.setShowGutter false
+  editor.setValue contents
+  editor.clearSelection()
+  session = editor.getSession()
+  session.setUseWorker false
+  session.setMode aceEditModes[codeLanguage]
+  session.setWrapLimitRange null
+  session.setUseWrapMode true
+  session.setNewLineMode 'unix'
+  return editor
diff --git a/app/schemas/models/level_session.coffee b/app/schemas/models/level_session.coffee
index 66a3bddb8..e2caf8b87 100644
--- a/app/schemas/models/level_session.coffee
+++ b/app/schemas/models/level_session.coffee
@@ -79,15 +79,15 @@ _.extend LevelSessionSchema.properties,
       currentScriptOffset:
         type: 'number'
 
-    selected:
+    selected:  # Not tracked any more, delete with old level types
       type: [
         'null'
         'string'
       ]
     playing:
-      type: 'boolean'  # Not tracked any more
+      type: 'boolean'  # Not tracked any more, delete with old level types
     frame:
-      type: 'number'  # Not tracked any more
+      type: 'number'  # Not tracked any more, delete with old level types
     thangs:   # ... what is this? Is this used?
       type: 'object'
       additionalProperties:
diff --git a/app/styles/play/level.sass b/app/styles/play/level.sass
index 82ad555d4..ad2a515c5 100644
--- a/app/styles/play/level.sass
+++ b/app/styles/play/level.sass
@@ -72,7 +72,13 @@ $level-resize-transition-time: 0.5s
     width: 55%
     position: relative
     overflow: hidden
-    @include transition($level-resize-transition-time ease-out)
+    @include transition(all $level-resize-transition-time ease-out, z-index 1.2s linear)
+    z-index: 0
+
+    &.preview-overlay
+      z-index: 20
+      #goals-view
+        visibility: hidden
     
   canvas#webgl-surface
     background-color: #333
diff --git a/app/styles/play/level/loading.sass b/app/styles/play/level/loading.sass
index 10c62bee3..5c8d6942b 100644
--- a/app/styles/play/level/loading.sass
+++ b/app/styles/play/level/loading.sass
@@ -8,24 +8,48 @@
   background-position: top $backgroundPosition
   background-size: contain
 
+$UNVEIL_TIME: 1.2s
+
 #level-loading-view
   width: 100%
   height: 100%
   position: absolute
   z-index: 20
-  $UNVEIL_TIME: 1.2s
 
   &.unveiled
     pointer-events: none
-  
-  .loading-details
+
+  &.preview-screen
+    background-color: rgba(0, 0, 0, 0.5)
+
+  .left-wing, .right-wing
+    width: 100%
+    height: 100%
+    position: absolute
+    pointer-events: none
+
+  .left-wing
+    @include wing-background('/images/level/loading_left_wing_1920.jpg', right)
+    @media screen and ( max-width: 1366px )
+      @include wing-background('/images/level/loading_left_wing_1366.jpg', right)
+    left: -50%
+    @include transition(all $UNVEIL_TIME ease)
+
+  .right-wing
+    @include wing-background('/images/level/loading_right_wing_1920.jpg', left)
+    @media screen and ( max-width: 1366px )
+      @include wing-background('/images/level/loading_right_wing_1366.jpg', left)
+    right: -50%
+    @include transition(all $UNVEIL_TIME ease)
+
+  #loading-details
     position: absolute
     top: 86px
-    left: 50%
+    right: 50%
     $WIDTH: 450px
     width: $WIDTH
     height: 450px
-    margin-left: (-$WIDTH / 2)
+    margin-right: (-$WIDTH / 2)
     z-index: 100
     background: transparent url(/images/level/code_editor_background.png) no-repeat
     background-size: 100% 100%
@@ -34,9 +58,22 @@
     padding: 80px 80px 40px 80px
     text-align: center
     // http://matthewlein.com/ceaser/  Bounce down a bit, then snap up.
-    @include transition(top $UNVEIL_TIME cubic-bezier(0.285, -0.595, 0.670, -0.600))
+    @include transition($UNVEIL_TIME cubic-bezier(0.285, -0.595, 0.670, -0.600))
     font-family: 'Open Sans Condensed'
 
+    &.preview
+      top: 0
+      right: 0
+      margin-right: 0
+      width: 45%
+      height: auto
+      pointer-events: all
+      @include transition($UNVEIL_TIME ease-in-out)
+
+      padding: 80px 70px 40px 50px
+      .progress-or-start-container.intro-footer
+        bottom: 30px
+
     .level-loading-goals
       text-align: left
 
@@ -49,12 +86,21 @@
         font-size: 20px
         color: black
 
+    .intro-doc
+      text-align: left
+      font-size: 16px
+      overflow: scroll
+
+      img
+        max-width: 100%
+
     .progress-or-start-container
       position: absolute
       bottom: 95px
-      width: 325px
       height: 80px
       left: 48px
+      right: 77px
+      @include transition(bottom $UNVEIL_TIME ease-out)
 
       .load-progress
         width: 100%
@@ -131,21 +177,7 @@
       width: 401px
       color: #666
 
-  .left-wing, .right-wing
-    width: 100%
-    height: 100%
-    position: absolute
-
-  .left-wing
-    @include wing-background('/images/level/loading_left_wing_1920.jpg', right)
-    @media screen and ( max-width: 1366px )
-      @include wing-background('/images/level/loading_left_wing_1366.jpg', right)
-    left: -50%
-    @include transition(all $UNVEIL_TIME ease)
-
-  .right-wing
-    @include wing-background('/images/level/loading_right_wing_1920.jpg', left)
-    @media screen and ( max-width: 1366px )
-      @include wing-background('/images/level/loading_right_wing_1366.jpg', left)
-    right: -50%
-    @include transition(all $UNVEIL_TIME ease)
+    &.preview #tip-wrapper
+      left: 48px
+      right: 77px
+      width: auto
diff --git a/app/styles/play/menu/guide-view.sass b/app/styles/play/menu/guide-view.sass
index 503097adf..25ce5b57b 100644
--- a/app/styles/play/menu/guide-view.sass
+++ b/app/styles/play/menu/guide-view.sass
@@ -52,3 +52,14 @@
       border-image: url(/images/level/code_toolbar_submit_button_zazz_pressed.png) 14 20 20 20 fill round
       padding: 2px 0 0 2px
       color: white
+
+#guide-view
+  pre.ace_editor
+    padding: 2px 4px
+    border-radius: 4px
+    background-color: #f9f2f4
+    font-size: 12px
+    font-family: Monaco, Menlo, Ubuntu Mono, Consolas, "source-code-pro", monospace !important
+
+    .ace_cursor, .ace_bracket
+      display: none
diff --git a/app/templates/play/level/level_loading.jade b/app/templates/play/level/level_loading.jade
index 693cfc297..71ef46fd3 100644
--- a/app/templates/play/level/level_loading.jade
+++ b/app/templates/play/level/level_loading.jade
@@ -2,7 +2,7 @@
 
 .right-wing
 
-.loading-details.loading-container
+#loading-details.loading-container
 
   .level-loading-goals.secret
     .goals-title(data-i18n="play_level.goals") Goals
@@ -10,6 +10,8 @@
 
   .errors
 
+  .intro-doc
+
   .progress-or-start-container
     button.start-level-button.btn.btn-lg.btn-success.btn-illustrated.header-font.needsclick(data-i18n="play_level.loading_start") Start Level
 
diff --git a/app/views/contribute/ArchmageView.coffee b/app/views/contribute/ArchmageView.coffee
index 7a428f5f5..93cdc9401 100644
--- a/app/views/contribute/ArchmageView.coffee
+++ b/app/views/contribute/ArchmageView.coffee
@@ -7,6 +7,7 @@ module.exports = class ArchmageView extends ContributeClassView
   contributorClassName: 'archmage'
 
   contributors: [
+    {id: '547acbb2af18b03c0563fdb3', name: 'David Liu', github: 'trotod'}
     {id: '52ccfc9bd3eb6b5a4100b60d', name: 'Glen De Cauwsemaecker', github: 'GlenDC'}
     {id: '52bfc3ecb7ec628868001297', name: 'Tom Steinbrecher', github: 'TomSteinbrecher'}
     {id: '5272806093680c5817033f73', name: 'Sébastien Moratinos', github: 'smoratinos'}
@@ -27,8 +28,8 @@ module.exports = class ArchmageView extends ContributeClassView
     {id: '531258b5e0789d4609614110', name: 'Ruben Vereecken', github: 'rubenvereecken'}
     {id: '5276ad5dcf83207a2801d3b4', name: 'Zach Martin', github: 'zachster01'}
     {id: '530df0cbc06854403ba67c15', name: 'Alexandru Caciulescu', github: 'Darredevil'}
-    {id: '5268d9baa39d7db617000b18', name: 'Thanish Muhammed', github: 'mnmtanish'}   
-    {id: '53232f458e54704b074b271d', name: 'Bang Honam', github: 'walkingtospace'}  
+    {id: '5268d9baa39d7db617000b18', name: 'Thanish Muhammed', github: 'mnmtanish'}
+    {id: '53232f458e54704b074b271d', name: 'Bang Honam', github: 'walkingtospace'}
     {id: '52d16c1dc931e2544d001daa', name: 'David Pendray', github: 'dpen2000'}
     {id: '53132ea1828a1706108ebb38', name: 'Dominik Kundel'}
     {id: '530eb29347a891b3518b3990', name: 'Ian Li'}
diff --git a/app/views/play/level/LevelLoadingView.coffee b/app/views/play/level/LevelLoadingView.coffee
index 3a7211d9a..68a6ca701 100644
--- a/app/views/play/level/LevelLoadingView.coffee
+++ b/app/views/play/level/LevelLoadingView.coffee
@@ -14,6 +14,7 @@ module.exports = class LevelLoadingView extends CocoView
 
   subscriptions:
     'level:loaded': 'onLevelLoaded'  # If Level loads after level loading view.
+    'level:session-loaded': 'onSessionLoaded'
     'level:subscription-required': 'onSubscriptionRequired'  # If they'd need a subscription to start playing.
     'level:course-membership-required': 'onCourseMembershipRequired'  # If they'd need a subscription to start playing.
     'subscribe-modal:subscribed': 'onSubscribed'
@@ -44,6 +45,14 @@ module.exports = class LevelLoadingView extends CocoView
 
   onLevelLoaded: (e) ->
     @level = e.level
+    @prepareGoals()
+    @prepareTip()
+    @prepareIntro()
+
+  onSessionLoaded: (e) ->
+    @session = e.session if e.session.get('creator') is me.id
+
+  prepareGoals: ->
     goalContainer = @$el.find('.level-loading-goals')
     goalList = goalContainer.find('ul')
     goalCount = 0
@@ -55,57 +64,120 @@ module.exports = class LevelLoadingView extends CocoView
       goalContainer.removeClass('secret')
       if goalCount is 1
         goalContainer.find('.panel-heading').text $.i18n.t 'play_level.goal'  # Not plural
+
+  prepareTip: ->
     tip = @$el.find('.tip')
     if @level.get('loadingTip')
       loadingTip = utils.i18n @level.attributes, 'loadingTip'
       tip.text(loadingTip)
     tip.removeClass('secret')
 
+  prepareIntro: ->
+    @docs = @level.get('documentation') ? {}
+    specific = @docs.specificArticles or []
+    return unless @intro = _.find specific, name: 'Intro'
+    @intro.html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body'))
+    @intro.name = utils.i18n @intro, 'name'
+
   showReady: ->
     return if @shownReady
     @shownReady = true
-    _.delay @finishShowingReady, 1500  # Let any blocking JS hog the main thread before we show that we're done.
+    _.delay @finishShowingReady, 100  # Let any blocking JS hog the main thread before we show that we're done.
 
   finishShowingReady: =>
     return if @destroyed
-    if @options.autoUnveil
+    if @options.autoUnveil or (@session?.get('state').complete and not @getQueryVariable('intro'))
       @startUnveiling()
-      @unveil()
+      @unveil true
     else
       @playSound 'level_loaded', 0.75  # old: loading_ready
       @$el.find('.progress').hide()
       @$el.find('.start-level-button').show()
+      @unveil false
 
   startUnveiling: (e) ->
     @playSound 'menu-button-click'
+    @unveiling = true
     Backbone.Mediator.publish 'level:loading-view-unveiling', {}
     _.delay @onClickStartLevel, 1000  # If they never mouse-up for the click (or a modal shows up and interrupts the click), do it anyway.
 
   onClickStartLevel: (e) =>
     return if @destroyed
-    @unveil()
+    @unveil true
 
   onEnterPressed: (e) ->
-    return unless @shownReady and not @$el.hasClass 'unveiled'
+    return unless @shownReady and not @unveiled
     @startUnveiling()
     @onClickStartLevel()
 
-  unveil: ->
-    return if @$el.hasClass 'unveiled'
-    @$el.addClass 'unveiled'
-    loadingDetails = @$el.find('.loading-details')
-    duration = parseFloat loadingDetails.css 'transition-duration'
-    loadingDetails.css 'top', -loadingDetails.outerHeight(true)
+  unveil: (full) ->
+    return if @destroyed or @unveiled
+    @unveiled = full
+    @$loadingDetails = @$el.find('#loading-details')
+    duration = parseFloat(@$loadingDetails.css 'transition-duration') * 1000
+    unless @$el.hasClass 'unveiled'
+      @$el.addClass 'unveiled'
+      @unveilWings duration
+    if full
+      @unveilLoadingFull()
+      _.delay @onUnveilEnded, duration
+    else
+      @unveilLoadingPreview duration
+
+  unveilLoadingFull: ->
+    # Get rid of the loading details screen entirely--the level is totally ready.
+    unless @unveiling
+      Backbone.Mediator.publish 'level:loading-view-unveiling', {}
+      @unveiling = true
+    if @$el.hasClass 'preview-screen'
+      @$loadingDetails.css 'right', -@$loadingDetails.outerWidth(true)
+    else
+      @$loadingDetails.css 'top', -@$loadingDetails.outerHeight(true)
+    @$el.removeClass 'preview-screen'
+    $('#canvas-wrapper').removeClass 'preview-overlay'
+
+  unveilLoadingPreview: (duration) ->
+    # Move the loading details screen over the code editor to preview the level.
+    return if @$el.hasClass 'preview-screen'
+    $('#canvas-wrapper').addClass 'preview-overlay'
+    @$el.addClass('preview-screen')
+    @$loadingDetails.addClass('preview')
+    @resize()
+    @onWindowResize = _.debounce @onWindowResize, 700  # Wait a bit for other views to resize before we resize
+    $(window).on 'resize', @onWindowResize
+    if @intro
+      @$el.find('.progress-or-start-container').addClass('intro-footer')
+      @$el.find('#tip-wrapper').remove()
+      _.delay @unveilIntro, duration
+
+  resize: ->
+    maxHeight = $('#page-container').outerHeight(true)
+    minHeight = $('#code-area').outerHeight(true)
+    @$el.css height: maxHeight
+    @$loadingDetails.css minHeight: minHeight, maxHeight: maxHeight
+    $intro = @$el.find('.intro-doc')
+    $intro.css maxHeight: minHeight - $intro.offset().top - @$el.find('.progress-or-start-container').outerHeight() - 30 - 20
+
+  unveilWings: (duration) ->
+    @playSound 'loading-view-unveil', 0.5
     @$el.find('.left-wing').css left: '-100%', backgroundPosition: 'right -400px top 0'
     @$el.find('.right-wing').css right: '-100%', backgroundPosition: 'left -400px top 0'
-    @playSound 'loading-view-unveil', 0.5
-    _.delay @onUnveilEnded, duration * 1000
-    $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration * 1000)
+    $('#level-footer-background').detach().appendTo('#page-container').slideDown(duration)
+
+  unveilIntro: =>
+    return if @destroyed or not @intro or @unveiled
+    @$el.find('.intro-doc').html @intro.html
+    @resize()
 
   onUnveilEnded: =>
     return if @destroyed
     Backbone.Mediator.publish 'level:loading-view-unveiled', view: @
 
+  onWindowResize: (e) =>
+    return if @destroyed
+    @$loadingDetails.css transition: 'none'
+    @resize()
+
   onSubscriptionRequired: (e) ->
     @$el.find('.level-loading-goals, .tip, .load-progress').hide()
     @$el.find('.subscription-required').show()
@@ -120,3 +192,7 @@ module.exports = class LevelLoadingView extends CocoView
 
   onSubscribed: ->
     document.location.reload()
+
+  destroy: ->
+    $(window).off 'resize', @onWindowResize
+    super()
diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee
index 627245cb3..f4f43f450 100644
--- a/app/views/play/level/PlayLevelView.coffee
+++ b/app/views/play/level/PlayLevelView.coffee
@@ -155,7 +155,7 @@ module.exports = class PlayLevelView extends RootView
   afterRender: ->
     super()
     window.onPlayLevelViewLoaded? @  # still a hack
-    @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level  # May not have @level loaded yet
+    @insertSubView @loadingView = new LevelLoadingView autoUnveil: @options.autoUnveil or @observing, level: @levelLoader?.level ? @level, session: @levelLoader?.session ? @session  # May not have @level loaded yet
     @$el.find('#level-done-button').hide()
     $('body').addClass('is-playing')
     $('body').bind('touchmove', false) if @isIPadApp()
@@ -177,7 +177,6 @@ module.exports = class PlayLevelView extends RootView
     @initVolume()
     @listenTo(@session, 'change:multiplayer', @onMultiplayerChanged)
 
-    @originalSessionState = $.extend(true, {}, @session.get('state'))
     @register()
     @controlBar.setBus(@bus)
     @initScriptManager()
@@ -341,14 +340,16 @@ module.exports = class PlayLevelView extends RootView
     if window.currentModal and not window.currentModal.destroyed and window.currentModal.constructor isnt VictoryModal
       return Backbone.Mediator.subscribeOnce 'modal:closed', @onLevelStarted, @
     @surface.showLevel()
+    Backbone.Mediator.publish 'level:set-time', time: 0
     if @isEditorPreview or @observing
       @loadingView.startUnveiling()
-      @loadingView.unveil()
+      @loadingView.unveil true
 
   onLoadingViewUnveiling: (e) ->
-    @restoreSessionState()
+    @selectHero()
 
   onLoadingViewUnveiled: (e) ->
+    Backbone.Mediator.publish 'level:set-playing', playing: true
     @loadingView.$el.remove()
     @removeSubView @loadingView
     @loadingView = null
@@ -372,21 +373,11 @@ module.exports = class PlayLevelView extends RootView
     @ambientSound = createjs.Sound.play src, loop: -1, volume: 0.1
     createjs.Tween.get(@ambientSound).to({volume: 1.0}, 10000)
 
-  restoreSessionState: ->
-    return if @alreadyLoadedState
-    @alreadyLoadedState = true
-    state = @originalSessionState
-    if not @level or @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder']
-      Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true
-      Backbone.Mediator.publish 'tome:select-primary-sprite', {}
-      Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false
-      @surface.focusOnHero()
-      Backbone.Mediator.publish 'level:set-time', time: 0
-      Backbone.Mediator.publish 'level:set-playing', playing: true
-    else
-      if state.selected
-        # TODO: Should also restore selected spell here by saving spellName
-        Backbone.Mediator.publish 'level:select-sprite', thangID: state.selected, spellName: null
+  selectHero: ->
+    Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: true
+    Backbone.Mediator.publish 'tome:select-primary-sprite', {}
+    Backbone.Mediator.publish 'level:suppress-selection-sounds', suppress: false
+    @surface.focusOnHero()
 
   # callbacks
 
diff --git a/app/views/play/level/tome/SpellPaletteEntryView.coffee b/app/views/play/level/tome/SpellPaletteEntryView.coffee
index 27c505502..9cdc15491 100644
--- a/app/views/play/level/tome/SpellPaletteEntryView.coffee
+++ b/app/views/play/level/tome/SpellPaletteEntryView.coffee
@@ -3,7 +3,7 @@ template = require 'templates/play/level/tome/spell_palette_entry'
 {me} = require 'core/auth'
 filters = require 'lib/image_filter'
 DocFormatter = require './DocFormatter'
-SpellView = require 'views/play/level/tome/SpellView'
+utils = require 'core/utils'
 
 module.exports = class SpellPaletteEntryView extends CocoView
   tagName: 'div'  # Could also try <code> instead of <div>, but would need to adjust colors
@@ -59,26 +59,8 @@ module.exports = class SpellPaletteEntryView extends CocoView
       @aceEditors = []
       aceEditors = @aceEditors
       popover?.$tip?.find('.docs-ace').each ->
-        contents = $(@).text()
-        editor = ace.edit @
-        editor.setOptions maxLines: Infinity
-        editor.setReadOnly true
-        editor.setTheme 'ace/theme/textmate'
-        editor.setShowPrintMargin false
-        editor.setShowFoldWidgets false
-        editor.setHighlightActiveLine false
-        editor.setHighlightActiveLine false
-        editor.setBehavioursEnabled false
-        editor.renderer.setShowGutter false
-        editor.setValue contents
-        editor.clearSelection()
-        session = editor.getSession()
-        session.setUseWorker false
-        session.setMode SpellView.editModes[codeLanguage]
-        session.setWrapLimitRange null
-        session.setUseWrapMode true
-        session.setNewLineMode 'unix'
-        aceEditors.push editor
+        aceEditor = utils.initializeACE @, codeLanguage
+        aceEditors.push aceEditor
 
   onMouseEnter: (e) ->
     # Make sure the doc has the updated Thang so it can regenerate its prop value
diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee
index 1e7214182..95be903e7 100644
--- a/app/views/play/level/tome/SpellView.coffee
+++ b/app/views/play/level/tome/SpellView.coffee
@@ -9,6 +9,7 @@ SpellDebugView = require './SpellDebugView'
 SpellToolbarView = require './SpellToolbarView'
 LevelComponent = require 'models/LevelComponent'
 UserCodeProblem = require 'models/UserCodeProblem'
+utils = require 'core/utils'
 
 module.exports = class SpellView extends CocoView
   id: 'spell-view'
@@ -18,14 +19,6 @@ module.exports = class SpellView extends CocoView
   eventsSuppressed: true
   writable: true
 
-  @editModes:
-    'javascript': 'ace/mode/javascript'
-    'coffeescript': 'ace/mode/coffee'
-    'python': 'ace/mode/python'
-    'clojure': 'ace/mode/clojure'
-    'lua': 'ace/mode/lua'
-    'io': 'ace/mode/text'
-
   keyBindings:
     'default': null
     'vim': 'ace/keyboard/vim'
@@ -93,7 +86,7 @@ module.exports = class SpellView extends CocoView
     @aceSession = @ace.getSession()
     @aceDoc = @aceSession.getDocument()
     @aceSession.setUseWorker false
-    @aceSession.setMode SpellView.editModes[@spell.language]
+    @aceSession.setMode utils.aceEditModes[@spell.language]
     @aceSession.setWrapLimitRange null
     @aceSession.setUseWrapMode true
     @aceSession.setNewLineMode 'unix'
@@ -479,7 +472,7 @@ module.exports = class SpellView extends CocoView
 
     # window.zatannaInstance = @zatanna  # For debugging. Make sure to not leave active when committing.
     # window.snippetEntries = snippetEntries
-    lang = SpellView.editModes[e.language].substr 'ace/mode/'.length
+    lang = utils.aceEditModes[e.language].substr 'ace/mode/'.length
     @zatanna.addSnippets snippetEntries, lang
     @editorLang = lang
 
@@ -1138,8 +1131,8 @@ module.exports = class SpellView extends CocoView
 
   onChangeLanguage: (e) ->
     return unless @spell.canWrite()
-    @aceSession.setMode SpellView.editModes[e.language]
-    @zatanna?.set 'language', SpellView.editModes[e.language].substr('ace/mode/')
+    @aceSession.setMode utils.aceEditModes[e.language]
+    @zatanna?.set 'language', utils.aceEditModes[e.language].substr('ace/mode/')
     wasDefault = @getSource() is @spell.originalSource
     @spell.setLanguage e.language
     @reloadCode true if wasDefault
diff --git a/app/views/play/menu/GuideView.coffee b/app/views/play/menu/GuideView.coffee
index 606c586b4..b1d727081 100644
--- a/app/views/play/menu/GuideView.coffee
+++ b/app/views/play/menu/GuideView.coffee
@@ -4,8 +4,6 @@ Article = require 'models/Article'
 SubscribeModal = require 'views/core/SubscribeModal'
 utils = require 'core/utils'
 
-# let's implement this once we have the docs database schema set up
-
 module.exports = class LevelGuideView extends CocoView
   template: template
   id: 'guide-view'
@@ -41,10 +39,10 @@ module.exports = class LevelGuideView extends CocoView
     @docs = specific.concat(general)
     @docs = $.extend(true, [], @docs)
     @docs = [@docs[0]] if @firstOnly and @docs[0]
-    doc.html = marked(@filterCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs
+    doc.html = marked(utils.filterMarkdownCodeLanguages(utils.i18n(doc, 'body'))) for doc in @docs
     doc.name = (utils.i18n doc, 'name') for doc in @docs
     doc.slug = _.string.slugify(doc.name) for doc in @docs
-    super()
+    super options
 
   destroy: ->
     if @vimeoListenerAttached
@@ -52,6 +50,7 @@ module.exports = class LevelGuideView extends CocoView
         window.removeEventListener('message', @onMessageReceived, false)
       else
         window.detachEvent('onmessage', @onMessageReceived, false)
+    oldEditor.destroy() for oldEditor in @aceEditors ? []
     super()
 
   getRenderData: ->
@@ -70,13 +69,17 @@ module.exports = class LevelGuideView extends CocoView
       @$el.find('.nav-tabs li:first').addClass('active')
       @$el.find('.tab-content .tab-pane:first').addClass('active')
       @$el.find('.nav-tabs a').click(@clickTab)
+    @configureACEEditors()
     @playSound 'guide-open'
 
-  filterCodeLanguages: (text) ->
-    currentLanguage = me.get('aceConfig')?.language or 'python'
-    excludedLanguages = _.without ['javascript', 'python', 'coffeescript', 'clojure', 'lua', 'io'], currentLanguage
-    exclusionRegex = new RegExp "```(#{excludedLanguages.join('|')})\n[^`]+```\n?", 'gm'
-    text.replace exclusionRegex, ''
+  configureACEEditors: ->
+    oldEditor.destroy() for oldEditor in @aceEditors ? []
+    @aceEditors = []
+    aceEditors = @aceEditors
+    codeLanguage = me.get('aceConfig')?.language or 'python'
+    @$el.find('pre').each ->
+      aceEditor = utils.initializeACE @, codeLanguage
+      aceEditors.push aceEditor
 
   clickSubscribe: (e) ->
     level = @levelSlug # Save ref to level slug

From 7e433b9e1246027b2974577805ba45ba80766912 Mon Sep 17 00:00:00 2001
From: Nick Winter <livelily@gmail.com>
Date: Tue, 10 Nov 2015 16:09:21 -0800
Subject: [PATCH 6/6] Use ?intro=true to show intro screen even after level
 completion

---
 app/views/play/level/LevelLoadingView.coffee | 11 ++++++-----
 app/views/play/level/PlayLevelView.coffee    |  2 +-
 2 files changed, 7 insertions(+), 6 deletions(-)

diff --git a/app/views/play/level/LevelLoadingView.coffee b/app/views/play/level/LevelLoadingView.coffee
index 68a6ca701..fde29cd63 100644
--- a/app/views/play/level/LevelLoadingView.coffee
+++ b/app/views/play/level/LevelLoadingView.coffee
@@ -75,9 +75,7 @@ module.exports = class LevelLoadingView extends CocoView
   prepareIntro: ->
     @docs = @level.get('documentation') ? {}
     specific = @docs.specificArticles or []
-    return unless @intro = _.find specific, name: 'Intro'
-    @intro.html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body'))
-    @intro.name = utils.i18n @intro, 'name'
+    @intro = _.find specific, name: 'Intro'
 
   showReady: ->
     return if @shownReady
@@ -86,7 +84,9 @@ module.exports = class LevelLoadingView extends CocoView
 
   finishShowingReady: =>
     return if @destroyed
-    if @options.autoUnveil or (@session?.get('state').complete and not @getQueryVariable('intro'))
+    showIntro = @getQueryVariable('intro')
+    autoUnveil = not showIntro and (@options.autoUnveil or @session?.get('state').complete)
+    if autoUnveil
       @startUnveiling()
       @unveil true
     else
@@ -166,7 +166,8 @@ module.exports = class LevelLoadingView extends CocoView
 
   unveilIntro: =>
     return if @destroyed or not @intro or @unveiled
-    @$el.find('.intro-doc').html @intro.html
+    html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body'))
+    @$el.find('.intro-doc').html html
     @resize()
 
   onUnveilEnded: =>
diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee
index f4f43f450..78828d08d 100644
--- a/app/views/play/level/PlayLevelView.coffee
+++ b/app/views/play/level/PlayLevelView.coffee
@@ -341,7 +341,7 @@ module.exports = class PlayLevelView extends RootView
       return Backbone.Mediator.subscribeOnce 'modal:closed', @onLevelStarted, @
     @surface.showLevel()
     Backbone.Mediator.publish 'level:set-time', time: 0
-    if @isEditorPreview or @observing
+    if (@isEditorPreview or @observing) and not @getQueryVariable('intro')
       @loadingView.startUnveiling()
       @loadingView.unveil true