Add graph to sub counts admin page
This commit is contained in:
parent
1dbb382318
commit
532e8133c6
4 changed files with 331 additions and 16 deletions
app
styles/admin
templates/admin
views/admin
server/payments
|
@ -1,19 +1,50 @@
|
||||||
#admin-analytics-subscriptions-view
|
#admin-analytics-subscriptions-view
|
||||||
|
|
||||||
.total-count
|
.big-stat
|
||||||
width: 33%
|
width: 25%
|
||||||
float: left
|
float: left
|
||||||
|
|
||||||
|
.total-count
|
||||||
color: green
|
color: green
|
||||||
.remaining-count
|
.remaining-count
|
||||||
width: 33%
|
|
||||||
float: left
|
|
||||||
color: blue
|
color: blue
|
||||||
.cancelled-count
|
.cancelled-count
|
||||||
width: 33%
|
|
||||||
float: left
|
|
||||||
color: red
|
color: red
|
||||||
|
.churn-count
|
||||||
|
color: orange
|
||||||
|
|
||||||
.count
|
.count
|
||||||
font-size: 50pt
|
font-size: 50pt
|
||||||
.description
|
.description
|
||||||
font-size: 8pt
|
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
|
||||||
|
|
|
@ -9,16 +9,32 @@ block content
|
||||||
h1 Fetching subscriptions data...
|
h1 Fetching subscriptions data...
|
||||||
else
|
else
|
||||||
div
|
div
|
||||||
.total-count
|
.big-stat.total-count
|
||||||
div.description Total
|
div.description Total
|
||||||
div.count= total
|
div.count= total
|
||||||
.remaining-count
|
.big-stat.remaining-count
|
||||||
div.description Remaining
|
div.description Remaining
|
||||||
div.count= total - cancelled
|
div.count= total - cancelled
|
||||||
.cancelled-count
|
.big-stat.cancelled-count
|
||||||
div.description cancelled
|
div.description Cancelled
|
||||||
div.count= 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
|
table.table.table-condensed.concepts-table
|
||||||
thead
|
thead
|
||||||
tr
|
tr
|
||||||
|
|
|
@ -2,6 +2,10 @@ RootView = require 'views/core/RootView'
|
||||||
template = require 'templates/admin/analytics-subscriptions'
|
template = require 'templates/admin/analytics-subscriptions'
|
||||||
RealTimeCollection = require 'collections/RealTimeCollection'
|
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'
|
require 'vendor/d3'
|
||||||
|
|
||||||
module.exports = class AnalyticsSubscriptionsView extends RootView
|
module.exports = class AnalyticsSubscriptionsView extends RootView
|
||||||
|
@ -16,16 +20,24 @@ module.exports = class AnalyticsSubscriptionsView extends RootView
|
||||||
|
|
||||||
getRenderData: ->
|
getRenderData: ->
|
||||||
context = super()
|
context = super()
|
||||||
|
context.analytics = @analytics
|
||||||
context.subs = @subs ? []
|
context.subs = @subs ? []
|
||||||
context.total = @total ? 0
|
context.total = @total ? 0
|
||||||
context.cancelled = @cancelled ? 0
|
context.cancelled = @cancelled ? 0
|
||||||
|
context.monthlyChurn = @monthlyChurn ? 0.0
|
||||||
context
|
context
|
||||||
|
|
||||||
|
afterRender: ->
|
||||||
|
super()
|
||||||
|
@updateAnalyticsGraphs()
|
||||||
|
|
||||||
refreshData: ->
|
refreshData: ->
|
||||||
return unless me.isAdmin()
|
return unless me.isAdmin()
|
||||||
|
@analytics = graphs: []
|
||||||
@subs = []
|
@subs = []
|
||||||
@total = 0
|
@total = 0
|
||||||
@cancelled = 0
|
@cancelled = 0
|
||||||
|
@monthlyChurn = 0.0
|
||||||
onSuccess = (subs) =>
|
onSuccess = (subs) =>
|
||||||
subDayMap = {}
|
subDayMap = {}
|
||||||
for sub in subs
|
for sub in subs
|
||||||
|
@ -42,15 +54,270 @@ module.exports = class AnalyticsSubscriptionsView extends RootView
|
||||||
day: day
|
day: day
|
||||||
started: subDayMap[day]['start']
|
started: subDayMap[day]['start']
|
||||||
cancelled: subDayMap[day]['cancel'] or 0
|
cancelled: subDayMap[day]['cancel'] or 0
|
||||||
@subs.sort (a, b) -> -a.day.localeCompare(b.day)
|
|
||||||
|
|
||||||
for i in [@subs.length - 1..0]
|
@subs.sort (a, b) -> a.day.localeCompare(b.day)
|
||||||
@total += @subs[i].started
|
startedLastMonth = 0
|
||||||
@cancelled += @subs[i].cancelled
|
for sub, i in @subs
|
||||||
@subs[i].total = @total
|
@total += sub.started
|
||||||
|
@cancelled += sub.cancelled
|
||||||
|
sub.total = @total
|
||||||
|
startedLastMonth += sub.started if @subs.length - i < 31
|
||||||
|
@monthlyChurn = @cancelled / startedLastMonth * 100.0
|
||||||
|
|
||||||
|
@updateAnalyticsGraphData()
|
||||||
@render()
|
@render()
|
||||||
@supermodel.addRequestResource('subscriptions', {
|
@supermodel.addRequestResource('subscriptions', {
|
||||||
url: '/db/subscription/-/subscriptions'
|
url: '/db/subscription/-/subscriptions'
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
success: onSuccess
|
success: onSuccess
|
||||||
}, 0).load()
|
}, 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++
|
||||||
|
|
|
@ -37,8 +37,9 @@ class SubscriptionHandler extends Handler
|
||||||
|
|
||||||
return @sendForbiddenError(res) unless req.user and req.user.isAdmin()
|
return @sendForbiddenError(res) unless req.user and req.user.isAdmin()
|
||||||
|
|
||||||
@subs ?= []
|
# @subs ?= []
|
||||||
# return @sendSuccess(res, @subs) unless _.isEmpty(@subs)
|
# return @sendSuccess(res, @subs) unless _.isEmpty(@subs)
|
||||||
|
@subs = []
|
||||||
|
|
||||||
customersProcessed = 0
|
customersProcessed = 0
|
||||||
nextBatch = (starting_after, done) =>
|
nextBatch = (starting_after, done) =>
|
||||||
|
|
Reference in a new issue