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    +    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 000000000..6a04f9e2e Binary files /dev/null and b/app/assets/images/pages/about/josh_c_small.png differ 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/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/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/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/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/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/about.jade b/app/templates/about.jade index 7a086a093..1f5a98f07 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 @@ -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 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/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/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 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..fde29cd63 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,121 @@ 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 [] + @intro = _.find specific, name: 'Intro' + 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 + showIntro = @getQueryVariable('intro') + autoUnveil = not showIntro and (@options.autoUnveil or @session?.get('state').complete) + if autoUnveil @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 + html = marked utils.filterMarkdownCodeLanguages(utils.i18n(@intro, 'body')) + @$el.find('.intro-doc').html 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 +193,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..78828d08d 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() - if @isEditorPreview or @observing + Backbone.Mediator.publish 'level:set-time', time: 0 + if (@isEditorPreview or @observing) and not @getQueryVariable('intro') @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 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;