diff --git a/app/Router.coffee b/app/Router.coffee index cce426ea1..c266a23ae 100644 --- a/app/Router.coffee +++ b/app/Router.coffee @@ -31,6 +31,7 @@ module.exports = class CocoRouter extends Backbone.Router 'admin/clas': go('admin/CLAsView') 'admin/employers': go('admin/EmployersListView') 'admin/files': go('admin/FilesView') + 'admin/growth': go('admin/GrowthView') 'admin/level-sessions': go('admin/LevelSessionsView') 'admin/users': go('admin/UsersView') 'admin/base': go('admin/BaseView') diff --git a/app/templates/admin.jade b/app/templates/admin.jade index af59ee831..36d754468 100644 --- a/app/templates/admin.jade +++ b/app/templates/admin.jade @@ -43,6 +43,9 @@ block content a(href="/admin/base", data-i18n="admin.av_other_debug_base_url") Base (for debugging base.jade) li a(href="/admin/clas", data-i18n="admin.clas") CLAs + if me.isAdmin() + li + a(href="/admin/growth", data-i18n="admin.growth") Growth hr diff --git a/app/templates/admin/growth.jade b/app/templates/admin/growth.jade new file mode 100644 index 000000000..0ff7a3c1d --- /dev/null +++ b/app/templates/admin/growth.jade @@ -0,0 +1,32 @@ +extends /templates/base + +block content + + h1(data-i18n="admin.growth_title") Growth + if me.isAdmin() + if crunchingData + h4 Cruncing Data.. + else + h2 Registered Users + h3 Per-Day + h4 Totals + svg.perDayTotal + h4 Added + svg.perDayAdded + table.table.table-striped.table-bordered.table-condensed + -for (var i = 0; i < usersPerDay.length; i++) + tr + td= usersPerDay[i].date + td= usersPerDay[i].added + td= usersPerDay[i].total + h3 Per-Month + h4 Totals + svg.perMonthTotal + h4 Added + svg.perMonthAdded + table.table.table-striped.table-bordered.table-condensed + -for (var i = 0; i < usersPerMonth.length; i++) + tr + td= usersPerMonth[i].date + td= usersPerMonth[i].added + td= usersPerMonth[i].total diff --git a/app/views/admin/GrowthView.coffee b/app/views/admin/GrowthView.coffee new file mode 100644 index 000000000..b77b88e92 --- /dev/null +++ b/app/views/admin/GrowthView.coffee @@ -0,0 +1,192 @@ +RootView = require 'views/kinds/RootView' +template = require 'templates/admin/growth' +RealTimeCollection = require 'collections/RealTimeCollection' + +# Growth View ################### +# +# Display interesting growth data. +# +# Currently shows: +# Registered user totals and added, per-day and per-month +# 7-day moving average for registered users added per-day +# +# TODO: @padding isn't applied correctly +# TODO: aggregate recent data if missing? +# + +module.exports = class GrowthView extends RootView + id: 'admin-growth-view' + template: template + height: 300 + width: 1000 + xAxisGuideHeight: 80 + yAxisGuideWidth: 60 + padding: 10 + + constructor: (options) -> + super options + @usersPerMonth = new RealTimeCollection 'growth/users/registered/per-month' + @usersPerMonth.on 'add', @refreshData + @usersPerDay = new RealTimeCollection 'growth/users/registered/per-day' + @usersPerDay.on 'add', @refreshData + + destroy: -> + @usersPerMonth.off 'add', @refreshData + @usersPerDay.off 'add', @refreshData + + refreshData: => + @render() + + getRenderData: -> + c = super() + c.crunchingData = @usersPerMonth.length is 0 and @usersPerDay.length is 0 + c.usersPerDay = [] + # @usersPerDay.each (item) -> + # c.usersPerDay.push date: item.get('id'), added: item.get('added'), total: item.get('total') + c.usersPerMonth = [] + # @usersPerMonth.each (item) -> + # c.usersPerMonth.push date: item.get('id'), added: item.get('added'), total: item.get('total') + c + + afterRender: -> + super() + if me.isAdmin() + @createPerDayChart() + @createPerMonthChart() + + createPerDayChart: -> + addedData = [] + totalData = [] + @usersPerDay.each (item) -> + addedData.push id: item.get('id'), value: item.get('added') + totalData.push id: item.get('id'), value: item.get('total') + @createLineChart ".perDayTotal", totalData, 1000 + @createLineChart ".perDayAdded", addedData, 10, true + + createPerMonthChart: -> + addedData = [] + totalData = [] + @usersPerMonth.each (item) -> + addedData.push id: item.get('id'), value: item.get('added') + totalData.push id: item.get('id'), value: item.get('total') + @createLineChart ".perMonthTotal", totalData, 1000 + @createLineChart ".perMonthAdded", addedData, 1000 + + createLineChart: (selector, data, guidelineSpacing, sevenDayAverage=false) -> + return unless data.length > 1 + + minVal = d3.min(data, (d) -> d.value) + maxVal = d3.max(data, (d) -> d.value) + + widthSpacing = (@width - @yAxisGuideWidth - @padding) / (data.length - 1) + + y = d3.scale.linear() + .domain([minVal, maxVal]) + .range([@height - @xAxisGuideHeight - 2 * @padding, 0]) + + points = [] + for i in [0...data.length] + points.push id: data[i].id, x: i * widthSpacing + @yAxisGuideWidth, y: y(data[i].value) + @padding + + links = [] + for i in [0...points.length - 1] + if points[i] and points[i + 1] + links.push start: points[i], end: points[i + 1] + + guidelines = [] + diff = maxVal - minVal + interval = Math.floor(diff / 5) + for i in [0..4] + yVal = i * interval + minVal + yVal = Math.floor(yVal / guidelineSpacing) * guidelineSpacing + guidelines.push start: {id: yVal, x: 0, y: y(yVal)}, end: {id: yVal, x: @width, y: y(yVal)} + + sevenPoints = [] + sevenLinks = [] + if sevenDayAverage + sevenTotal = 0 + for i in [0...data.length] + sevenTotal += data[i].value + if i > 5 + sevenAvg = sevenTotal / 7 + sevenPoints.push x: i * widthSpacing + @yAxisGuideWidth, y: y(sevenAvg) + @padding + if i > 6 + sevenTotal -= data[i - 7].value + for i in [0...sevenPoints.length - 1] + if sevenPoints[i] and sevenPoints[i + 1] + sevenLinks.push start: sevenPoints[i], end: sevenPoints[i + 1] + + chart = d3.select(selector) + .attr("width", @width) + .attr("height", @height) + + chart.selectAll(".circle") + .data(points) + .enter() + .append("circle") + .attr("cx", (d) -> d.x ) + .attr("cy", (d) -> d.y ) + .attr("r", "2px") + .attr("fill", "black") + + chart.selectAll(".text") + .data(points) + .enter() + .append("text") + .attr("dy", ".35em") + .attr("transform", (d, i) => "translate(" + d.x + "," + @height + ") rotate(270)") + .text((d) -> + if d.id.length is 8 + return "#{parseInt(d.id[4..5])}/#{parseInt(d.id[6..7])}/#{d.id[0..3]}" + else + return "#{parseInt(d.id[4..5])}/#{d.id[0..3]}" + ) + + chart.selectAll('.line') + .data(links) + .enter() + .append("line") + .attr("x1", (d) -> d.start.x ) + .attr("y1", (d) -> d.start.y ) + .attr("x2", (d) -> d.end.x ) + .attr("y2", (d) -> d.end.y ) + .style("stroke", "rgb(6,120,155)") + + chart.selectAll(".circle") + .data(sevenPoints) + .enter() + .append("circle") + .attr("cx", (d) -> d.x ) + .attr("cy", (d) -> d.y ) + .attr("r", "2px") + .attr("fill", "purple") + + chart.selectAll('.line') + .data(sevenLinks) + .enter() + .append("line") + .attr("x1", (d) -> d.start.x ) + .attr("y1", (d) -> d.start.y ) + .attr("x2", (d) -> d.end.x ) + .attr("y2", (d) -> d.end.y ) + .style("stroke", "rgb(200,0,0)") + + chart.selectAll('.line') + .data(guidelines) + .enter() + .append("line") + .attr("x1", (d) -> d.start.x ) + .attr("y1", (d) -> d.start.y ) + .attr("x2", (d) -> d.end.x ) + .attr("y2", (d) -> d.end.y ) + .style("stroke", "rgb(140,140,140)") + + chart.selectAll(".text") + .data(guidelines) + .enter() + .append("text") + .attr("x", (d) -> d.start.x) + .attr("y", (d) -> d.start.y - 6) + .attr("dy", ".35em") + .text((d) -> d.start.id) +