diff --git a/app/styles/admin/analytics-subscriptions.sass b/app/styles/admin/analytics-subscriptions.sass index 34206b2ca..b4a6eb0af 100644 --- a/app/styles/admin/analytics-subscriptions.sass +++ b/app/styles/admin/analytics-subscriptions.sass @@ -1,19 +1,50 @@ #admin-analytics-subscriptions-view - .total-count - width: 33% + .big-stat + width: 25% float: left + + .total-count color: green .remaining-count - width: 33% - float: left color: blue .cancelled-count - width: 33% - float: left color: red + .churn-count + color: orange .count font-size: 50pt .description font-size: 8pt + + .line-graph-label + font-size: 10pt + font-weight: normal + .line-graph-container + height: 500px + width: 100% + + // TODO: figure out why this is necessary + margin-bottom: 100px + + .x.axis + font-size: 9pt + path + display: none + .y.axis + font-size: 9pt + path + display: none + .key-line + font-size: 9pt + .key-text + font-size: 9pt + .graph-point-info-container + display: none + position: absolute + padding: 10px + border: 1px solid black + z-index: 3 + background-color: blanchedalmond + font-size: 10pt diff --git a/app/templates/admin/analytics-subscriptions.jade b/app/templates/admin/analytics-subscriptions.jade index 7369adf42..b1887b007 100644 --- a/app/templates/admin/analytics-subscriptions.jade +++ b/app/templates/admin/analytics-subscriptions.jade @@ -9,16 +9,32 @@ block content h1 Fetching subscriptions data... else div - .total-count + .big-stat.total-count div.description Total div.count= total - .remaining-count + .big-stat.remaining-count div.description Remaining div.count= total - cancelled - .cancelled-count - div.description cancelled + .big-stat.cancelled-count + div.description Cancelled div.count= cancelled + .big-stat.churn-count + div.description Monthly Churn + div.count #{monthlyChurn.toFixed(2)}% + each graph in analytics.graphs + .line-graph-container + each line in graph.lines + each point in line.points + .graph-point-info-container(data-pointid="#{point.pointID}") + div(style='font-weight:bold;') #{point.day} + each value in point.values + div #{value} + + div *Stripe APIs do not return information about inactive subs. + + br + table.table.table-condensed.concepts-table thead tr diff --git a/app/views/admin/AnalyticsSubscriptionsView.coffee b/app/views/admin/AnalyticsSubscriptionsView.coffee index 886dda4e8..b1f60ae65 100644 --- a/app/views/admin/AnalyticsSubscriptionsView.coffee +++ b/app/views/admin/AnalyticsSubscriptionsView.coffee @@ -2,6 +2,10 @@ RootView = require 'views/core/RootView' template = require 'templates/admin/analytics-subscriptions' RealTimeCollection = require 'collections/RealTimeCollection' +# TODO: Add revenue line +# TODO: Add LTV line +# TODO: Graphing code copied/mangled from campaign editor level view. OMG, DRY. + require 'vendor/d3' module.exports = class AnalyticsSubscriptionsView extends RootView @@ -16,16 +20,24 @@ module.exports = class AnalyticsSubscriptionsView extends RootView getRenderData: -> context = super() + context.analytics = @analytics context.subs = @subs ? [] context.total = @total ? 0 context.cancelled = @cancelled ? 0 + context.monthlyChurn = @monthlyChurn ? 0.0 context + afterRender: -> + super() + @updateAnalyticsGraphs() + refreshData: -> return unless me.isAdmin() + @analytics = graphs: [] @subs = [] @total = 0 @cancelled = 0 + @monthlyChurn = 0.0 onSuccess = (subs) => subDayMap = {} for sub in subs @@ -42,15 +54,270 @@ module.exports = class AnalyticsSubscriptionsView extends RootView day: day started: subDayMap[day]['start'] cancelled: subDayMap[day]['cancel'] or 0 - @subs.sort (a, b) -> -a.day.localeCompare(b.day) - for i in [@subs.length - 1..0] - @total += @subs[i].started - @cancelled += @subs[i].cancelled - @subs[i].total = @total + @subs.sort (a, b) -> a.day.localeCompare(b.day) + startedLastMonth = 0 + for sub, i in @subs + @total += sub.started + @cancelled += sub.cancelled + sub.total = @total + startedLastMonth += sub.started if @subs.length - i < 31 + @monthlyChurn = @cancelled / startedLastMonth * 100.0 + + @updateAnalyticsGraphData() @render() @supermodel.addRequestResource('subscriptions', { url: '/db/subscription/-/subscriptions' method: 'GET' success: onSuccess }, 0).load() + + + updateAnalyticsGraphData: -> + # console.log 'updateAnalyticsGraphData' + # Build graphs based on available @analytics data + # Currently only one graph + @analytics.graphs = [graphID: 'total-subs', lines: []] + + return unless @subs?.length > 0 + + # TODO: Where should this metadata live? + # TODO: lineIDs assumed to be unique across graphs + totalSubsID = 'total-subs' + startedSubsID = 'started-subs' + cancelledSubsID = 'cancelled-subs' + lineMetadata = {} + lineMetadata[totalSubsID] = + description: 'Total Active Subscriptions' + color: 'green' + lineMetadata[startedSubsID] = + description: 'New Subscriptions' + color: 'blue' + lineMetadata[cancelledSubsID] = + description: 'Cancelled Subscriptions' + color: 'red' + + days = (sub.day for sub in @subs) + if days.length > 0 + currentIndex = 0 + currentDay = days[currentIndex] + currentDate = new Date(currentDay + "T00:00:00.000Z") + lastDay = days[days.length - 1] + while currentDay isnt lastDay + days.splice currentIndex, 0, currentDay if days[currentIndex] isnt currentDay + currentIndex++ + currentDate.setUTCDate(currentDate.getUTCDate() + 1) + currentDay = currentDate.toISOString().substr(0, 10) + + ## Totals + + # Build line data + levelPoints = [] + for sub, i in @subs + levelPoints.push + x: i + y: sub.total + day: sub.day + pointID: "#{totalSubsID}#{i}" + values: [] + + # Ensure points for each day + for day, i in days + if levelPoints.length <= i or levelPoints[i].day isnt day + prevY = if i > 0 then levelPoints[i - 1].y else 0.0 + levelPoints.splice i, 0, + y: prevY + day: day + values: [] + levelPoints[i].x = i + levelPoints[i].pointID = "#{totalSubsID}#{i}" + + levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60 + + @analytics.graphs[0].lines.push + lineID: totalSubsID + enabled: true + points: levelPoints + description: lineMetadata[totalSubsID].description + lineColor: lineMetadata[totalSubsID].color + min: 0 + max: d3.max(@subs, (d) -> d.total) + + ## Started + + # Build line data + levelPoints = [] + for sub, i in @subs + levelPoints.push + x: i + y: sub.started + day: sub.day + pointID: "#{startedSubsID}#{i}" + values: [] + + # Ensure points for each day + for day, i in days + if levelPoints.length <= i or levelPoints[i].day isnt day + prevY = if i > 0 then levelPoints[i - 1].y else 0.0 + levelPoints.splice i, 0, + y: prevY + day: day + values: [] + levelPoints[i].x = i + levelPoints[i].pointID = "#{startedSubsID}#{i}" + + levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60 + + @analytics.graphs[0].lines.push + lineID: startedSubsID + enabled: true + points: levelPoints + description: lineMetadata[startedSubsID].description + lineColor: lineMetadata[startedSubsID].color + min: 0 + max: d3.max(@subs, (d) -> d.started) + + ## Cancelled + + # Build line data + levelPoints = [] + for sub, i in @subs + levelPoints.push + x: i + y: sub.cancelled + day: sub.day + pointID: "#{cancelledSubsID}#{i}" + values: [] + + # Ensure points for each day + for day, i in days + if levelPoints.length <= i or levelPoints[i].day isnt day + prevY = if i > 0 then levelPoints[i - 1].y else 0.0 + levelPoints.splice i, 0, + y: prevY + day: day + values: [] + levelPoints[i].x = i + levelPoints[i].pointID = "#{cancelledSubsID}#{i}" + + levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60 + + @analytics.graphs[0].lines.push + lineID: cancelledSubsID + enabled: true + points: levelPoints + description: lineMetadata[cancelledSubsID].description + lineColor: lineMetadata[cancelledSubsID].color + min: 0 + max: d3.max(@subs, (d) -> d.started) + + updateAnalyticsGraphs: -> + # Build d3 graphs + return unless @analytics?.graphs?.length > 0 + containerSelector = '.line-graph-container' + # console.log 'updateAnalyticsGraphs', containerSelector, @analytics.graphs + + margin = 20 + keyHeight = 20 + xAxisHeight = 20 + yAxisWidth = 40 + containerWidth = $(containerSelector).width() + containerHeight = $(containerSelector).height() + + for graph in @analytics.graphs + graphLineCount = _.reduce graph.lines, ((sum, item) -> if item.enabled then sum + 1 else sum), 0 + svg = d3.select(containerSelector).append("svg") + .attr("width", containerWidth) + .attr("height", containerHeight) + width = containerWidth - margin * 2 - yAxisWidth * graphLineCount + height = containerHeight - margin * 2 - xAxisHeight - keyHeight * graphLineCount + currentLine = 0 + for line in graph.lines + 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 and guideline once + 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 * (graphLineCount - 1)) + "," + (height + margin) + ")") + .style("text-anchor", "start") + + # Horizontal guidelines + # svg.selectAll(".line") + # .data([10, 30, 50, 70, 90]) + # .enter() + # .append("line") + # .attr("x1", margin + yAxisWidth * graphLineCount) + # .attr("y1", (d) -> margin + yRange(d)) + # .attr("x2", margin + yAxisWidth * graphLineCount + width) + # .attr("y2", (d) -> margin + yRange(d)) + # .attr("stroke", line.lineColor) + # .style("opacity", "0.5") + + # 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 * currentLine) + "," + margin + ")") + .style("color", line.lineColor) + .call(yAxis) + .selectAll("text") + .attr("y", 0) + .attr("x", 0) + .attr("fill", line.lineColor) + .style("text-anchor", "start") + + # 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 * graphLineCount) + "," + 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 * graphLineCount) + "," + margin + ")") + .style("stroke-width", 1) + .style("stroke", line.lineColor) + .style("fill", "none") + currentLine++ diff --git a/server/payments/subscription_handler.coffee b/server/payments/subscription_handler.coffee index 9db6f8780..79005ea0f 100644 --- a/server/payments/subscription_handler.coffee +++ b/server/payments/subscription_handler.coffee @@ -37,8 +37,9 @@ class SubscriptionHandler extends Handler return @sendForbiddenError(res) unless req.user and req.user.isAdmin() - @subs ?= [] + # @subs ?= [] # return @sendSuccess(res, @subs) unless _.isEmpty(@subs) + @subs = [] customersProcessed = 0 nextBatch = (starting_after, done) =>