mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-12-04 12:51:12 -05:00
6831355649
Use Stripe events API to calculate monthly churn. Move Stripe API page handling to the client.
741 lines
27 KiB
CoffeeScript
741 lines
27 KiB
CoffeeScript
RootView = require 'views/core/RootView'
|
|
template = require 'templates/admin/analytics-subscriptions'
|
|
ThangType = require 'models/ThangType'
|
|
User = require 'models/User'
|
|
|
|
# TODO: Graphing code copied/mangled from campaign editor level view. OMG, DRY.
|
|
|
|
require 'vendor/d3'
|
|
|
|
module.exports = class AnalyticsSubscriptionsView extends RootView
|
|
id: 'admin-analytics-subscriptions-view'
|
|
template: template
|
|
targetSubCount: 1200
|
|
|
|
constructor: (options) ->
|
|
super options
|
|
@resetSubscriptionsData()
|
|
if me.isAdmin()
|
|
@refreshData()
|
|
_.delay (=> @refreshData()), 30 * 60 * 1000
|
|
|
|
getRenderData: ->
|
|
context = super()
|
|
context.analytics = @analytics ? graphs: []
|
|
context.cancellations = @cancellations ? []
|
|
context.subs = _.cloneDeep(@subs ? []).reverse()
|
|
context.subscribers = @subscribers ? []
|
|
context.subscriberCancelled = _.find context.subscribers, (subscriber) -> subscriber.cancel
|
|
context.subscriberSponsored = _.find context.subscribers, (subscriber) -> subscriber.user?.stripe?.sponsorID
|
|
context.total = @total ? 0
|
|
context.cancelled = @cancellations?.length ? @cancelled ? 0
|
|
context.monthlyChurn = @monthlyChurn ? 0.0
|
|
context.monthlyGrowth = @monthlyGrowth ? 0.0
|
|
context.outstandingCancels = @outstandingCancels ? []
|
|
context.refreshDataState = @refreshDataState
|
|
context
|
|
|
|
afterRender: ->
|
|
super()
|
|
@updateAnalyticsGraphs()
|
|
|
|
resetSubscriptionsData: ->
|
|
@analytics = graphs: []
|
|
@subs = []
|
|
@total = 0
|
|
@cancelled = 0
|
|
@monthlyChurn = 0.0
|
|
@monthlyGrowth = 0.0
|
|
@refreshDataState = 'Fetching dashboard data...'
|
|
|
|
refreshData: ->
|
|
return unless me.isAdmin()
|
|
@resetSubscriptionsData()
|
|
@getCancellations (cancellations) =>
|
|
@cancellations = cancellations
|
|
@render?()
|
|
@getOutstandingCancelledSubscriptions cancellations, (outstandingCancels) =>
|
|
@outstandingCancels = outstandingCancels
|
|
@getSubscriptions cancellations, (subscriptions) =>
|
|
@updateAnalyticsGraphData()
|
|
@render?()
|
|
@getSubscribers subscriptions, =>
|
|
@render?()
|
|
|
|
updateFetchDataState: (msg) ->
|
|
@refreshDataState = msg
|
|
@render?()
|
|
|
|
getCancellations: (done) ->
|
|
cancellations = []
|
|
@getCancellationEvents (cancelledSubscriptions) =>
|
|
# Get user objects for cancelled subscriptions
|
|
userIDs = _.map cancelledSubscriptions, (a) -> a.userID
|
|
options =
|
|
url: '/db/user/-/users'
|
|
method: 'POST'
|
|
data: {ids: userIDs}
|
|
options.error = (model, response, options) =>
|
|
return if @destroyed
|
|
console.error 'Failed to get cancelled users', response
|
|
options.success = (cancelledUsers, response, options) =>
|
|
return if @destroyed
|
|
userMap = {}
|
|
userMap[user._id] = user for user in cancelledUsers
|
|
for cancellation in cancelledSubscriptions when cancellation.userID of userMap
|
|
cancellation.user = userMap[cancellation.userID]
|
|
cancellation.level = User.levelFromExp(cancellation.user.points)
|
|
cancelledSubscriptions.sort (a, b) -> if a.cancel > b.cancel then -1 else 1
|
|
done(cancelledSubscriptions)
|
|
@updateFetchDataState 'Fetching cancellations...'
|
|
@supermodel.addRequestResource('get_cancelled_users', options, 0).load()
|
|
|
|
getCancellationEvents: (done) ->
|
|
cancellationEvents = []
|
|
earliestEventDate = new Date()
|
|
earliestEventDate.setUTCMonth(earliestEventDate.getUTCMonth() - 1)
|
|
earliestEventDate.setUTCDate(earliestEventDate.getUTCDate() - 8)
|
|
nextBatch = (starting_after, done) =>
|
|
@updateFetchDataState "Fetching cancellations #{cancellationEvents.length}..."
|
|
options =
|
|
url: '/db/subscription/-/stripe_events'
|
|
method: 'POST'
|
|
data: {options: {limit: 100}}
|
|
options.data.options.starting_after = starting_after if starting_after
|
|
options.data.options.type = 'customer.subscription.updated'
|
|
options.data.options.created = gte: Math.floor(earliestEventDate.getTime() / 1000)
|
|
options.error = (model, response, options) =>
|
|
return if @destroyed
|
|
console.error 'Failed to get cancelled events', response
|
|
options.success = (events, response, options) =>
|
|
return if @destroyed
|
|
for event in events.data
|
|
continue unless event.data?.object?.cancel_at_period_end is true and event.data?.previous_attributes.cancel_at_period_end is false
|
|
continue unless event.data?.object?.plan?.id is 'basic'
|
|
continue unless event.data?.object?.id?
|
|
cancellationEvents.push
|
|
cancel: new Date(event.created * 1000)
|
|
customerID: event.data.object.customer
|
|
start: new Date(event.data.object.start * 1000)
|
|
subscriptionID: event.data.object.id
|
|
userID: event.data.object.metadata?.id
|
|
|
|
if events.has_more
|
|
return nextBatch(events.data[events.data.length - 1].id, done)
|
|
done(cancellationEvents)
|
|
@supermodel.addRequestResource('get_cancellation_events', options, 0).load()
|
|
nextBatch null, done
|
|
|
|
getOutstandingCancelledSubscriptions: (cancellations, done) ->
|
|
@updateFetchDataState "Fetching oustanding cancellations..."
|
|
options =
|
|
url: '/db/subscription/-/stripe_subscriptions'
|
|
method: 'POST'
|
|
data: {subscriptions: cancellations}
|
|
options.error = (model, response, options) =>
|
|
return if @destroyed
|
|
console.error 'Failed to get outstanding cancellations', response
|
|
options.success = (subscriptions, response, options) =>
|
|
return if @destroyed
|
|
outstandingCancelledSubscriptions = []
|
|
for subscription in subscriptions
|
|
continue unless subscription?.cancel_at_period_end
|
|
outstandingCancelledSubscriptions.push
|
|
cancel: new Date(subscription.canceled_at * 1000)
|
|
customerID: subscription.customerID
|
|
start: new Date(subscription.start * 1000)
|
|
subscriptionID: subscription.id
|
|
userID: subscription.metadata?.id
|
|
done(outstandingCancelledSubscriptions)
|
|
@supermodel.addRequestResource('get_outstanding_cancelled_subscriptions', options, 0).load()
|
|
|
|
getSubscribers: (subscriptions, done) ->
|
|
# console.log 'getSubscribers', subscriptions.length
|
|
@updateFetchDataState "Fetching recipient subscriptions..."
|
|
@render?()
|
|
maxSubscribers = 40
|
|
|
|
subscribers = _.filter subscriptions, (a) -> a.userID?
|
|
subscribers.sort (a, b) -> if a.start > b.start then -1 else 1
|
|
subscribers = subscribers.slice(0, maxSubscribers)
|
|
subscriberUserIDs = _.map subscribers, (a) -> a.userID
|
|
|
|
options =
|
|
url: '/db/subscription/-/subscribers'
|
|
method: 'POST'
|
|
data: {ids: subscriberUserIDs}
|
|
options.error = (model, response, options) =>
|
|
return if @destroyed
|
|
console.error 'Failed to get subscribers', response
|
|
options.success = (userMap, response, options) =>
|
|
return if @destroyed
|
|
for subscriber in subscribers
|
|
continue unless subscriber.userID of userMap
|
|
subscriber.user = userMap[subscriber.userID]
|
|
subscriber.level = User.levelFromExp subscriber.user.points
|
|
if hero = subscriber.user.heroConfig?.thangType
|
|
subscriber.hero = _.invert(ThangType.heroes)[hero]
|
|
@subscribers = subscribers
|
|
done()
|
|
@supermodel.addRequestResource('get_subscribers', options, 0).load()
|
|
|
|
getSubscriptions: (cancellations=[], done) ->
|
|
@getInvoices (invoices) =>
|
|
subMap = {}
|
|
for invoice in invoices
|
|
subID = invoice.subscriptionID
|
|
if subID of subMap
|
|
subMap[subID].first = new Date(invoice.date)
|
|
else
|
|
subMap[subID] =
|
|
first: new Date(invoice.date)
|
|
last: new Date(invoice.date)
|
|
customerID: invoice.customerID
|
|
subMap[subID].userID = invoice.userID if invoice.userID
|
|
|
|
@getSponsors (sponsors) =>
|
|
@getRecipientSubscriptions sponsors, (recipientSubscriptions) =>
|
|
@updateFetchDataState "Fetching recipient subscriptions..."
|
|
for subscription in recipientSubscriptions
|
|
subMap[subscription.id] =
|
|
first: new Date(subscription.start * 1000)
|
|
subMap[subscription.id].userID = subscription.metadata.id if subscription.metadata?.id?
|
|
if subscription.cancel_at_period_end
|
|
subMap[subscription.id].cancel = new Date(subscription.canceled_at * 1000)
|
|
subMap[subscription.id].end = new Date(subscription.current_period_end * 1000)
|
|
|
|
subs = []
|
|
for subID of subMap
|
|
sub =
|
|
customerID: subMap[subID].customerID
|
|
start: subMap[subID].first
|
|
subscriptionID: subID
|
|
sub.cancel = subMap[subID].cancel if subMap[subID].cancel
|
|
oneMonthAgo = new Date()
|
|
oneMonthAgo.setUTCMonth(oneMonthAgo.getUTCMonth() - 1)
|
|
if subMap[subID].end?
|
|
sub.end = subMap[subID].end
|
|
else if subMap[subID].last < oneMonthAgo
|
|
sub.end = subMap[subID].last
|
|
sub.end.setUTCMonth(sub.end.getUTCMonth() + 1)
|
|
sub.userID = subMap[subID].userID if subMap[subID].userID
|
|
subs.push sub
|
|
|
|
subDayMap = {}
|
|
for sub in subs
|
|
startDay = sub.start.toISOString().substring(0, 10)
|
|
subDayMap[startDay] ?= {}
|
|
subDayMap[startDay]['start'] ?= 0
|
|
subDayMap[startDay]['start']++
|
|
if endDay = sub?.end?.toISOString().substring(0, 10)
|
|
subDayMap[endDay] ?= {}
|
|
subDayMap[endDay]['end'] ?= 0
|
|
subDayMap[endDay]['end']++
|
|
for cancellation in cancellations
|
|
if cancellation.subscriptionID is sub.subscriptionID
|
|
sub.cancel = cancellation.cancel
|
|
cancelDay = cancellation.cancel.toISOString().substring(0, 10)
|
|
subDayMap[cancelDay] ?= {}
|
|
subDayMap[cancelDay]['cancel'] ?= 0
|
|
subDayMap[cancelDay]['cancel']++
|
|
break
|
|
|
|
today = new Date().toISOString().substring(0, 10)
|
|
for day of subDayMap
|
|
continue if day > today
|
|
@subs.push
|
|
day: day
|
|
started: subDayMap[day]['start'] or 0
|
|
cancelled: subDayMap[day]['cancel'] or 0
|
|
ended: subDayMap[day]['end'] or 0
|
|
|
|
@subs.sort (a, b) -> a.day.localeCompare(b.day)
|
|
totalLastMonth = 0
|
|
for sub, i in @subs
|
|
@total += sub.started
|
|
@total -= sub.ended
|
|
@cancelled += sub.cancelled
|
|
sub.total = @total
|
|
totalLastMonth = @total if @subs.length - i is 31
|
|
@monthlyChurn = @cancelled / totalLastMonth * 100.0 if totalLastMonth > 0
|
|
if @subs.length > 30 and @subs[@subs.length - 31].total > 0
|
|
startMonthTotal = @subs[@subs.length - 31].total
|
|
endMonthTotal = @subs[@subs.length - 1].total
|
|
@monthlyGrowth = (endMonthTotal / startMonthTotal - 1) * 100
|
|
done(subs)
|
|
|
|
getInvoices: (done) ->
|
|
invoices = {}
|
|
nextBatch = (starting_after, done) =>
|
|
@updateFetchDataState "Fetching invoices #{Object.keys(invoices).length}..."
|
|
options =
|
|
url: '/db/subscription/-/stripe_invoices'
|
|
method: 'POST'
|
|
data: {options: {limit: 100}}
|
|
options.data.options.starting_after = starting_after if starting_after
|
|
options.error = (model, response, options) =>
|
|
return if @destroyed
|
|
console.error 'Failed to get invoices', response
|
|
options.success = (invoiceData, response, options) =>
|
|
return if @destroyed
|
|
for invoice in invoiceData.data
|
|
continue unless invoice.paid
|
|
continue unless invoice.subscription
|
|
continue unless invoice.total > 0
|
|
continue unless invoice.lines?.data?[0]?.plan?.id is 'basic'
|
|
invoices[invoice.id] =
|
|
customerID: invoice.customer
|
|
subscriptionID: invoice.subscription
|
|
date: new Date(invoice.date * 1000)
|
|
invoices[invoice.id].userID = invoice.lines.data[0].metadata.id if invoice.lines?.data?[0]?.metadata?.id
|
|
if invoiceData.has_more
|
|
return nextBatch(invoiceData.data[invoiceData.data.length - 1].id, done)
|
|
else
|
|
invoices = (invoice for invoiceID, invoice of invoices)
|
|
invoices.sort (a, b) -> if a.date > b.date then -1 else 1
|
|
return done(invoices)
|
|
@supermodel.addRequestResource('get_invoices', options, 0).load()
|
|
nextBatch null, done
|
|
|
|
getRecipientSubscriptions: (sponsors, done) ->
|
|
@updateFetchDataState "Fetching recipient subscriptions..."
|
|
subscriptionsToFetch = []
|
|
for user in sponsors
|
|
for recipient in user.stripe?.recipients
|
|
subscriptionsToFetch.push
|
|
customerID: user.stripe.customerID
|
|
subscriptionID: recipient.subscriptionID
|
|
options =
|
|
url: '/db/subscription/-/stripe_subscriptions'
|
|
method: 'POST'
|
|
data: {subscriptions: subscriptionsToFetch}
|
|
options.error = (model, response, options) =>
|
|
return if @destroyed
|
|
console.error 'Failed to get recipient subscriptions', response
|
|
options.success = (subscriptions, response, options) =>
|
|
return if @destroyed
|
|
done(subscriptions)
|
|
@supermodel.addRequestResource('get_recipient_subscriptions', options, 0).load()
|
|
|
|
getSponsors: (done) ->
|
|
@updateFetchDataState "Fetching sponsors..."
|
|
options =
|
|
url: '/db/user/-/sub_sponsors'
|
|
method: 'POST'
|
|
options.error = (model, response, options) =>
|
|
return if @destroyed
|
|
console.error 'Failed to get sponsors', response
|
|
options.success = (sponsors, response, options) =>
|
|
return if @destroyed
|
|
done(sponsors)
|
|
@supermodel.addRequestResource('get_sponsors', options, 0).load()
|
|
|
|
updateAnalyticsGraphData: ->
|
|
# console.log 'updateAnalyticsGraphData'
|
|
# Build graphs based on available @analytics data
|
|
# Currently only one graph
|
|
@analytics.graphs = []
|
|
|
|
return unless @subs?.length > 0
|
|
|
|
@addGraphData(60)
|
|
@addGraphData(180, true)
|
|
|
|
addGraphData: (timeframeDays, skipCancelled=false) ->
|
|
graph = {graphID: 'total-subs', lines: []}
|
|
|
|
# TODO: Where should this metadata live?
|
|
# TODO: lineIDs assumed to be unique across graphs
|
|
totalSubsID = 'total-subs'
|
|
targetSubsID = 'target-subs'
|
|
startedSubsID = 'started-subs'
|
|
cancelledSubsID = 'cancelled-subs'
|
|
netSubsID = 'net-subs'
|
|
averageNewID = 'average-new'
|
|
lineMetadata = {}
|
|
lineMetadata[totalSubsID] =
|
|
description: 'Total Active Subscriptions'
|
|
color: 'green'
|
|
strokeWidth: 1
|
|
lineMetadata[targetSubsID] =
|
|
description: 'Target Total Subscriptions'
|
|
color: 'gold'
|
|
strokeWidth: 4
|
|
opacity: 1.0
|
|
lineMetadata[startedSubsID] =
|
|
description: 'New Subscriptions'
|
|
color: 'blue'
|
|
strokeWidth: 1
|
|
lineMetadata[cancelledSubsID] =
|
|
description: 'Cancelled Subscriptions'
|
|
color: 'red'
|
|
strokeWidth: 1
|
|
lineMetadata[netSubsID] =
|
|
description: '7-day Average Net Subscriptions (started - cancelled)'
|
|
color: 'black'
|
|
strokeWidth: 4
|
|
lineMetadata[averageNewID] =
|
|
description: '7-day Average New Subscriptions'
|
|
color: 'black'
|
|
strokeWidth: 4
|
|
|
|
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 - timeframeDays) if levelPoints.length > timeframeDays
|
|
|
|
graph.lines.push
|
|
lineID: totalSubsID
|
|
enabled: true
|
|
points: levelPoints
|
|
description: lineMetadata[totalSubsID].description
|
|
lineColor: lineMetadata[totalSubsID].color
|
|
strokeWidth: lineMetadata[totalSubsID].strokeWidth
|
|
min: 0
|
|
max: Math.max(@targetSubCount, 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 - timeframeDays) if levelPoints.length > timeframeDays
|
|
|
|
graph.lines.push
|
|
lineID: startedSubsID
|
|
enabled: true
|
|
points: levelPoints
|
|
description: lineMetadata[startedSubsID].description
|
|
lineColor: lineMetadata[startedSubsID].color
|
|
strokeWidth: lineMetadata[startedSubsID].strokeWidth
|
|
min: 0
|
|
max: d3.max(@subs[-timeframeDays..], (d) -> d.started + 2)
|
|
|
|
if skipCancelled
|
|
|
|
## 7-Day average started
|
|
|
|
# Build line data
|
|
levelPoints = []
|
|
sevenStarts = []
|
|
for sub, i in @subs
|
|
average = 0
|
|
sevenStarts.push sub.started
|
|
if sevenStarts.length > 7
|
|
sevenStarts.shift()
|
|
if sevenStarts.length is 7
|
|
average = sevenStarts.reduce((a, b) -> a + b) / sevenStarts.length
|
|
levelPoints.push
|
|
x: i
|
|
y: average
|
|
day: sub.day
|
|
pointID: "#{averageNewID}#{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 = "#{averageNewID}#{i}"
|
|
|
|
levelPoints.splice(0, levelPoints.length - timeframeDays) if levelPoints.length > timeframeDays
|
|
|
|
graph.lines.push
|
|
lineID: averageNewID
|
|
enabled: true
|
|
points: levelPoints
|
|
description: lineMetadata[averageNewID].description
|
|
lineColor: lineMetadata[averageNewID].color
|
|
strokeWidth: lineMetadata[averageNewID].strokeWidth
|
|
min: 0
|
|
max: d3.max(@subs[-timeframeDays..], (d) -> d.started + 2)
|
|
|
|
else
|
|
|
|
## Total subs target
|
|
|
|
# Build line data
|
|
levelPoints = []
|
|
for sub, i in @subs
|
|
levelPoints.push
|
|
x: i
|
|
y: @targetSubCount
|
|
day: sub.day
|
|
pointID: "#{targetSubsID}#{i}"
|
|
values: []
|
|
|
|
levelPoints.splice(0, levelPoints.length - timeframeDays) if levelPoints.length > timeframeDays
|
|
|
|
graph.lines.push
|
|
lineID: targetSubsID
|
|
enabled: true
|
|
points: levelPoints
|
|
description: lineMetadata[targetSubsID].description
|
|
lineColor: lineMetadata[targetSubsID].color
|
|
strokeWidth: lineMetadata[targetSubsID].strokeWidth
|
|
min: 0
|
|
max: Math.max(@targetSubCount, d3.max(@subs, (d) -> d.total))
|
|
|
|
## Cancelled
|
|
|
|
# TODO: move this average cancelled stuff up the chain
|
|
averageCancelled = 0
|
|
|
|
# Build line data
|
|
levelPoints = []
|
|
cancelled = []
|
|
for sub, i in @subs[@subs.length - 30...]
|
|
cancelled.push sub.cancelled
|
|
levelPoints.push
|
|
x: @subs.length - 30 + i
|
|
y: sub.cancelled
|
|
day: sub.day
|
|
pointID: "#{cancelledSubsID}#{@subs.length - 30 + i}"
|
|
values: []
|
|
averageCancelled = cancelled.reduce((a, b) -> a + b) / cancelled.length
|
|
for sub, i in @subs[0...-30]
|
|
levelPoints.splice i, 0,
|
|
x: i
|
|
y: averageCancelled
|
|
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 - timeframeDays) if levelPoints.length > timeframeDays
|
|
|
|
graph.lines.push
|
|
lineID: cancelledSubsID
|
|
enabled: true
|
|
points: levelPoints
|
|
description: lineMetadata[cancelledSubsID].description
|
|
lineColor: lineMetadata[cancelledSubsID].color
|
|
strokeWidth: lineMetadata[cancelledSubsID].strokeWidth
|
|
min: 0
|
|
max: d3.max(@subs[-timeframeDays..], (d) -> d.started + 2)
|
|
|
|
## 7-Day Net Subs
|
|
|
|
# Build line data
|
|
levelPoints = []
|
|
sevenNets = []
|
|
for sub, i in @subs
|
|
net = 0
|
|
if i >= @subs.length - 30
|
|
sevenNets.push sub.started - sub.cancelled
|
|
else
|
|
sevenNets.push sub.started - averageCancelled
|
|
if sevenNets.length > 7
|
|
sevenNets.shift()
|
|
if sevenNets.length is 7
|
|
net = sevenNets.reduce((a, b) -> a + b) / 7
|
|
levelPoints.push
|
|
x: i
|
|
y: net
|
|
day: sub.day
|
|
pointID: "#{netSubsID}#{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 = "#{netSubsID}#{i}"
|
|
|
|
levelPoints.splice(0, levelPoints.length - timeframeDays) if levelPoints.length > timeframeDays
|
|
|
|
graph.lines.push
|
|
lineID: netSubsID
|
|
enabled: true
|
|
points: levelPoints
|
|
description: lineMetadata[netSubsID].description
|
|
lineColor: lineMetadata[netSubsID].color
|
|
strokeWidth: lineMetadata[netSubsID].strokeWidth
|
|
min: 0
|
|
max: d3.max(@subs[-timeframeDays..], (d) -> d.started + 2)
|
|
|
|
@analytics.graphs.push(graph)
|
|
|
|
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 * 2
|
|
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
|
|
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.lineID is 'started-subs'
|
|
# Horizontal guidelines
|
|
marks = (Math.round(i * line.max / 5) for i in [1...5])
|
|
svg.selectAll(".line")
|
|
.data(marks)
|
|
.enter()
|
|
.append("line")
|
|
.attr("x1", margin + yAxisWidth * 2)
|
|
.attr("y1", (d) -> margin + yRange(d))
|
|
.attr("x2", margin + yAxisWidth * 2 + width)
|
|
.attr("y2", (d) -> margin + yRange(d))
|
|
.attr("stroke", line.lineColor)
|
|
.style("opacity", "0.5")
|
|
|
|
if currentLine < 2
|
|
# 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", if line.lineColor is 'gold' then 'orange' else 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 * 2) + "," + 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 * 2) + "," + margin + ")")
|
|
.style("stroke-width", line.strokeWidth)
|
|
.style("stroke", line.lineColor)
|
|
.style("fill", "none")
|
|
currentLine++
|