Merge branch 'master' into production

This commit is contained in:
Nick Winter 2015-03-27 16:25:41 -07:00
commit 8be5278f98
17 changed files with 541 additions and 41 deletions

View file

@ -33,7 +33,8 @@ module.exports = class CocoRouter extends Backbone.Router
'admin/clas': go('admin/CLAsView') 'admin/clas': go('admin/CLAsView')
'admin/employers': go('admin/EmployersListView') 'admin/employers': go('admin/EmployersListView')
'admin/files': go('admin/FilesView') 'admin/files': go('admin/FilesView')
'admin/growth': go('admin/GrowthView') 'admin/analytics/users': go('admin/AnalyticsUsersView')
'admin/analytics/subscriptions': go('admin/AnalyticsSubscriptionsView')
'admin/level-sessions': go('admin/LevelSessionsView') 'admin/level-sessions': go('admin/LevelSessionsView')
'admin/users': go('admin/UsersView') 'admin/users': go('admin/UsersView')
'admin/base': go('admin/BaseView') 'admin/base': go('admin/BaseView')

View file

@ -62,6 +62,9 @@ module.exports = class LevelLoader extends CocoClass
# Session Loading # Session Loading
loadSession: -> loadSession: ->
if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']
@sessionDependenciesRegistered = {}
if @sessionID if @sessionID
url = "/db/level.session/#{@sessionID}" url = "/db/level.session/#{@sessionID}"
else else
@ -103,7 +106,6 @@ module.exports = class LevelLoader extends CocoClass
else if session is @opponentSession else if session is @opponentSession
@consolidateFlagHistory() if @session.loaded @consolidateFlagHistory() if @session.loaded
return unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] return unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']
@sessionDependenciesRegistered ?= {}
heroConfig = session.get('heroConfig') heroConfig = session.get('heroConfig')
heroConfig ?= me.get('heroConfig') if session is @session and not @headless heroConfig ?= me.get('heroConfig') if session is @session and not @headless
heroConfig ?= {} heroConfig ?= {}

View file

@ -25,7 +25,7 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese",
code: "コード" code: "コード"
admin: "管理" # Only shows up when you are an admin admin: "管理" # Only shows up when you are an admin
home: "ホーム" home: "ホーム"
contribute: "貢献" contribute: "コントリビュート"
legal: "規約" legal: "規約"
about: "CoCoについて" about: "CoCoについて"
contact: "お問い合わせ" contact: "お問い合わせ"
@ -828,24 +828,24 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese",
polls: polls:
priority: "プライオリティ" priority: "プライオリティ"
# contribute: contribute:
# page_title: "Contributing" page_title: "コントリビュート"
# intro_blurb: "CodeCombat is 100% open source! Hundreds of dedicated players have helped us build the game into what it is today. Join us and write the next chapter in CodeCombat's quest to teach the world to code!" intro_blurb: "CodeCombat は100%オープンソースです!何百もの熱心なプレイヤーが私たちがゲームを作るのを手伝っています。私たちと一緒に CodeCombat の次のチャプターを作って世界中のプレイヤーにプログラミングを教えましょう!"
# alert_account_message_intro: "Hey there!" # alert_account_message_intro: "Hey there!"
# alert_account_message: "To subscribe for class emails, you'll need to be logged in first." # alert_account_message: "To subscribe for class emails, you'll need to be logged in first."
# archmage_introduction: "One of the best parts about building games is they synthesize so many different things. Graphics, sound, real-time networking, social networking, and of course many of the more common aspects of programming, from low-level database management, and server administration to user facing design and interface building. There's a lot to do, and if you're an experienced programmer with a hankering to really dive into the nitty-gritty of CodeCombat, this class might be for you. We would love to have your help building the best programming game ever." archmage_introduction: "ゲームを作る上で一番重要なのは、たくさんの要素を合成することです。グラフィック、サウンド、リアルタイムネットワーキング、ソーシャルネットワーキング、一般的なプログラミング、ローレベルのデータベースマネジメント、管理画面のデザインやインターフェイスなど多岐に渡ります。やらなくてはいけないことはたくさんあります。もしあなたが経験豊富なプログラマであればアーキメイジになって CodeCombat のコアにコミットしましょう。ぜひとも私たちの最高のプログラミングゲームを手伝ってください。"
# class_attributes: "Class Attributes" class_attributes: "クラスの属性"
# archmage_attribute_1_pref: "Knowledge in " archmage_attribute_1_pref: ""
# archmage_attribute_1_suf: ", or a desire to learn. Most of our code is in this language. If you're a fan of Ruby or Python, you'll feel right at home. It's JavaScript, but with a nicer syntax." archmage_attribute_1_suf: " の知識か、それを学ぶ欲求。ほとんどの私たちのコードはこの言語で書かれています。もしあなたが Ruby や Python のファンなら親しく感じるでしょう。JavaScript ですが、より素敵なシンタックスです。"
# archmage_attribute_2: "Some experience in programming and personal initiative. We'll help you get oriented, but we can't spend much time training you." archmage_attribute_2: "プログラミングの経験や、自ら率先して行動すること。私たちは慣れるのをお手伝いしますが、あなたをトレーニングする時間はありません。"
# how_to_join: "How To Join" how_to_join: "参加の方法"
# join_desc_1: "Anyone can help out! Just check out our " join_desc_1: "誰でも"
# join_desc_2: "to get started, and check the box below to mark yourself as a brave Archmage and get the latest news by email. Want to chat about what to do or how to get more deeply involved? " join_desc_2: "からはじめることができます。また、下のチェックボックをオンにするとアークメイジと CodeCombat の最新情報がメールで届きます。さらに深く一翼を担いたいですか?"
# join_desc_3: ", or find us in our " join_desc_3: "をするか、私たちの"
# join_desc_4: "and we'll go from there!" join_desc_4: "で私たちに連絡してください!"
# join_url_email: "Email us" join_url_email: "メール"
# join_url_hipchat: "public HipChat room" join_url_hipchat: "公開の HipChat のルーム"
# archmage_subscribe_desc: "Get emails on new coding opportunities and announcements." archmage_subscribe_desc: "コーディングの機会やアナウンスをメールで受け取る"
# artisan_introduction_pref: "We must construct additional levels! People be clamoring for more content, and we can only build so many ourselves. Right now your workstation is level one; our level editor is barely usable even by its creators, so be wary. If you have visions of campaigns spanning for-loops to" # artisan_introduction_pref: "We must construct additional levels! People be clamoring for more content, and we can only build so many ourselves. Right now your workstation is level one; our level editor is barely usable even by its creators, so be wary. If you have visions of campaigns spanning for-loops to"
# artisan_introduction_suf: ", then this class might be for you." # artisan_introduction_suf: ", then this class might be for you."
# artisan_attribute_1: "Any experience in building content like this would be nice, such as using Blizzard's level editors. But not required!" # artisan_attribute_1: "Any experience in building content like this would be nice, such as using Blizzard's level editors. But not required!"
@ -890,7 +890,7 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese",
# ambassador_subscribe_desc: "Get emails on support updates and multiplayer developments." # ambassador_subscribe_desc: "Get emails on support updates and multiplayer developments."
# changes_auto_save: "Changes are saved automatically when you toggle checkboxes." # changes_auto_save: "Changes are saved automatically when you toggle checkboxes."
# diligent_scribes: "Our Diligent Scribes:" # diligent_scribes: "Our Diligent Scribes:"
# powerful_archmages: "Our Powerful Archmages:" powerful_archmages: "私たちの強力なアークメイジたち:"
# creative_artisans: "Our Creative Artisans:" # creative_artisans: "Our Creative Artisans:"
# brave_adventurers: "Our Brave Adventurers:" # brave_adventurers: "Our Brave Adventurers:"
# translating_diplomats: "Our Translating Diplomats:" # translating_diplomats: "Our Translating Diplomats:"

View file

@ -941,13 +941,13 @@ module.exports = nativeDescription: "Português (Portugal)", englishDescription:
fight: "Lutar!" fight: "Lutar!"
watch_victory: "Vê a tua vitória" watch_victory: "Vê a tua vitória"
defeat_the: "Derrota o" defeat_the: "Derrota o"
# tournament_started: ", started" tournament_started: ", começou"
tournament_ends: "O Torneio acaba" tournament_ends: "O Torneio acaba"
tournament_ended: "O Torneio acabou" tournament_ended: "O Torneio acabou"
tournament_rules: "Regras do Torneio" tournament_rules: "Regras do Torneio"
tournament_blurb: "Escreve código, recolhe ouro, constrói exércitos, esmaga inimigos, ganha prémios e melhora a tua carreira no nosso torneio $40,000 Greed! Confere os detalhes" tournament_blurb: "Escreve código, recolhe ouro, constrói exércitos, esmaga inimigos, ganha prémios e melhora a tua carreira no nosso torneio $40,000 Greed! Confere os detalhes"
tournament_blurb_criss_cross: "Ganha ofertas, constrói caminhos, supera os adversários, apanha gemas e melhore a tua carreira no nosso torneio Criss-Cross! Confere os detalhes" tournament_blurb_criss_cross: "Ganha ofertas, constrói caminhos, supera os adversários, apanha gemas e melhore a tua carreira no nosso torneio Criss-Cross! Confere os detalhes"
# tournament_blurb_zero_sum: "Unleash your coding creativity in both gold gathering and battle tactics in this alpine mirror match between red sorcerer and blue sorcerer. The tournament began on Friday, March 27 and will run until Monday, April 6 at 5PM PDT. Compete for fun and glory! Check out the details" tournament_blurb_zero_sum: "Liberta a tua criatividade de programação tanto na recolha de ouro como em táticas de combate nesta batalha-espelhada na montaha, entre o feiticeiro vermelho e o feiticeiro azul. O torneio começou na Sexta-feira, 27 de Março, e decorrerá até às 00:00 de Terça-feira, 7 de Abril. Compete por diversão e glória! Confere os detalhes"
tournament_blurb_blog: "no nosso blog" tournament_blurb_blog: "no nosso blog"
rules: "Regras" rules: "Regras"
winners: "Vencedores" winners: "Vencedores"

View file

@ -941,13 +941,13 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi
fight: "В бой!" fight: "В бой!"
watch_victory: "Наблюдать за победой" watch_victory: "Наблюдать за победой"
defeat_the: "Победить" defeat_the: "Победить"
# tournament_started: ", started" tournament_started: ", начат"
tournament_ends: "Турнир заканчивается" tournament_ends: "Турнир заканчивается"
tournament_ended: "Турнир закончился" tournament_ended: "Турнир закончился"
tournament_rules: "Правила турнира" tournament_rules: "Правила турнира"
tournament_blurb: "Пишите код, собирайте золото, стройте армию, крушите противников, получайте призы и улучшайте вашу карьеру в нашем \"$40,000 турнире жадности\"! Узнайте больше" tournament_blurb: "Пишите код, собирайте золото, стройте армию, крушите противников, получайте призы и улучшайте вашу карьеру в нашем \"$40,000 турнире жадности\"! Узнайте больше"
tournament_blurb_criss_cross: "Выигрывайте ставки, создавайте пути, перехитрите оппонентов, собирайте самоцветы и улучшайте вашу карьеру в нашем турнире Criss-Cross! Узнайте больше" tournament_blurb_criss_cross: "Выигрывайте ставки, создавайте пути, перехитрите оппонентов, собирайте самоцветы и улучшайте вашу карьеру в нашем турнире Criss-Cross! Узнайте больше"
# tournament_blurb_zero_sum: "Unleash your coding creativity in both gold gathering and battle tactics in this alpine mirror match between red sorcerer and blue sorcerer. The tournament began on Friday, March 27 and will run until Monday, April 6 at 5PM PDT. Compete for fun and glory! Check out the details" tournament_blurb_zero_sum: "Дайте волю своей программистской фантазии в собирании золота и боевой тактике в этом высокогорном зеркальном матче между красным волшебником и синим волшебником. Турнир начался в пятницу, 27 марта, и продолжится до 17.00 PDT понедельника, 6 апреля. Участвуйте для веселья и славы! Посмотрите детали"
tournament_blurb_blog: "в нашем блоге" tournament_blurb_blog: "в нашем блоге"
rules: "Правила" rules: "Правила"
winners: "Победители" winners: "Победители"

View file

@ -0,0 +1,50 @@
#admin-analytics-subscriptions-view
.big-stat
width: 25%
float: left
.total-count
color: green
.remaining-count
color: blue
.cancelled-count
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

View file

@ -299,7 +299,7 @@ kbd
&.btn-primary &.btn-primary
border-image-source: url(/images/common/button-background-primary-disabled-border.png) border-image-source: url(/images/common/button-background-primary-disabled-border.png)
&.btn-success &.btn-success
border-image-source: url(/images/common/button-background-success-disabled-border.png) border-image-source: url(/images/common/button-background-success-inactive-border.png)
&.btn-warning &.btn-warning
border-image-source: url(/images/common/button-background-warning-disabled-border.png) border-image-source: url(/images/common/button-background-warning-disabled-border.png)
&.btn-danger &.btn-danger
@ -357,7 +357,7 @@ html.no-borderimage
&.btn-primary &.btn-primary
background-image: url(/images/common/button-background-primary-disabled.png) background-image: url(/images/common/button-background-primary-disabled.png)
&.btn-success &.btn-success
background-image: url(/images/common/button-background-success-disabled.png) background-image: url(/images/common/button-background-success-inactive.png)
&.btn-warning &.btn-warning
background-image: url(/images/common/button-background-warning-disabled.png) background-image: url(/images/common/button-background-warning-disabled.png)
&.btn-danger &.btn-danger

View file

@ -225,6 +225,7 @@ $gameControlMargin: 30px
img img
display: block display: block
margin: 0px auto margin: 0px auto
max-width: 100%
.level-status .level-status
background: transparent url(/images/pages/play/level-info-status-spritesheet.png) no-repeat 0 0 background: transparent url(/images/pages/play/level-info-status-spritesheet.png) no-repeat 0 0

View file

@ -44,8 +44,12 @@ block content
li li
a(href="/admin/clas", data-i18n="admin.clas") CLAs a(href="/admin/clas", data-i18n="admin.clas") CLAs
if me.isAdmin() if me.isAdmin()
li Analytics
ul
li li
a(href="/admin/growth", data-i18n="admin.growth") Growth a(href="/admin/analytics/subscriptions") Subscriptions
li
a(href="/admin/analytics/users") Users (needs updating)
if me.isAdmin() if me.isAdmin()
hr hr

View file

@ -0,0 +1,51 @@
extends /templates/base
block content
if !me.isAdmin()
div You must be logged in as an admin to view this page.
else
if total === 0
h1 Fetching subscriptions data...
else
div
.big-stat.total-count
div.description Total
div.count= total
.big-stat.remaining-count
div.description Remaining
div.count= total - 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
th Day
th Total
th Started
th Cancelled
tbody
each sub in subs
tr
td= sub.day
td= sub.total
td= sub.started
td= sub.cancelled

View file

@ -2,7 +2,7 @@ extends /templates/base
block content block content
h1(data-i18n="admin.growth_title") Growth h1(data-i18n="admin.growth_title") Users
if me.isAdmin() if me.isAdmin()
if crunchingData if crunchingData
h4 Crunching Data.. h4 Crunching Data..

View file

@ -0,0 +1,323 @@
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
id: 'admin-analytics-subscriptions-view'
template: template
constructor: (options) ->
super options
if me.isAdmin()
@refreshData()
_.delay (=> @refreshData()), 30 * 60 * 1000
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
startDay = sub.start.substring(0, 10)
subDayMap[startDay] ?= {}
subDayMap[startDay]['start'] ?= 0
subDayMap[startDay]['start']++
if cancelDay = sub?.cancel?.substring(0, 10)
subDayMap[cancelDay] ?= {}
subDayMap[cancelDay]['cancel'] ?= 0
subDayMap[cancelDay]['cancel']++
for day of subDayMap
@subs.push
day: day
started: subDayMap[day]['start']
cancelled: subDayMap[day]['cancel'] or 0
@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++

View file

@ -1,5 +1,5 @@
RootView = require 'views/core/RootView' RootView = require 'views/core/RootView'
template = require 'templates/admin/growth' template = require 'templates/admin/analytics-users'
RealTimeCollection = require 'collections/RealTimeCollection' RealTimeCollection = require 'collections/RealTimeCollection'
require 'vendor/d3' require 'vendor/d3'
@ -16,8 +16,8 @@ require 'vendor/d3'
# TODO: aggregate recent data if missing? # TODO: aggregate recent data if missing?
# #
module.exports = class GrowthView extends RootView module.exports = class AnalyticsUsersView extends RootView
id: 'admin-growth-view' id: 'admin-analytics-users-view'
template: template template: template
height: 300 height: 300
width: 1000 width: 1000
@ -191,4 +191,3 @@ module.exports = class GrowthView extends RootView
.attr("y", (d) -> d.start.y - 6) .attr("y", (d) -> d.start.y - 6)
.attr("dy", ".35em") .attr("dy", ".35em")
.text((d) -> d.start.id) .text((d) -> d.start.id)

View file

@ -24,6 +24,7 @@ module.exports.handlers =
'poll': 'polls/poll_handler' 'poll': 'polls/poll_handler'
'user_polls_record': 'polls/user_polls_record_handler' 'user_polls_record': 'polls/user_polls_record_handler'
'prepaid': 'prepaids/prepaid_handler' 'prepaid': 'prepaids/prepaid_handler'
'subscription': 'payments/subscription_handler'
module.exports.routes = module.exports.routes =
[ [

View file

@ -2,6 +2,7 @@
# the stripe property in the user with what's being stored in Stripe. # the stripe property in the user with what's being stored in Stripe.
async = require 'async' async = require 'async'
config = require '../../server_config'
Handler = require '../commons/Handler' Handler = require '../commons/Handler'
discountHandler = require './discount_handler' discountHandler = require './discount_handler'
Prepaid = require '../prepaids/Prepaid' Prepaid = require '../prepaids/Prepaid'
@ -21,6 +22,71 @@ class SubscriptionHandler extends Handler
logSubscriptionError: (user, msg) -> logSubscriptionError: (user, msg) ->
console.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{msg}'" console.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{msg}'"
getByRelationship: (req, res, args...) ->
return @getSubscriptions(req, res) if args[1] is 'subscriptions'
super(arguments...)
getSubscriptions: (req, res) ->
# Returns a list of active subscriptions
# TODO: does not handle customers with 11+ active subscriptions
# TODO: does not track sponsored subs, only basic
# TODO: does not return free subs
# TODO: add tests
# TODO: aggregate this data daily instead of providing it on demand
# TODO: take date range as input
return @sendForbiddenError(res) unless req.user and req.user.isAdmin()
# @subs ?= []
# return @sendSuccess(res, @subs) unless _.isEmpty(@subs)
@subs = []
customersProcessed = 0
nextBatch = (starting_after, done) =>
options = limit: 100
options.starting_after = starting_after if starting_after
stripe.customers.list options, (err, customers) =>
return done(err) if err
customersProcessed += customers.data.length
for customer in customers.data
continue unless customer?.subscriptions?.data?.length > 0
for subscription in customer.subscriptions.data
continue unless subscription.plan.id is 'basic'
amount = subscription.plan.amount
if subscription?.discount?.coupon?
if subscription.discount.coupon.percent_off
amount = amount * (100 - subscription.discount.coupon.percent_off) / 100;
else if subscription.discount.coupon.amount_off
amount -= subscription.discount.coupon.amount_off
else if customer.discount?.coupon?
if customer.discount.coupon.percent_off
amount = amount * (100 - customer.discount.coupon.percent_off) / 100
else if customer.discount.coupon.amount_off
amount -= customer.discount.coupon.amount_off
continue unless amount > 0
sub = start: new Date(subscription.start * 1000)
if subscription.cancel_at_period_end
sub.cancel = new Date(subscription.canceled_at * 1000)
sub.end = new Date(sub.current_period_end * 1000)
@subs.push(sub)
# Can't fetch all the test Stripe data
if customers.has_more and (config.isProduction or customersProcessed < 500)
return nextBatch(customers.data[customers.data.length - 1].id, done)
else
return done()
nextBatch null, (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, @subs)
subscribeUser: (req, user, done) -> subscribeUser: (req, user, done) ->
if (not req.user) or req.user.isAnonymous() or user.isAnonymous() if (not req.user) or req.user.isAnonymous() or user.isAnonymous()
return done({res: 'You must be signed in to subscribe.', code: 403}) return done({res: 'You must be signed in to subscribe.', code: 403})

View file

@ -419,6 +419,8 @@ describe 'Subscriptions', ->
options = customer: body.stripe.customerID, limit: 100 options = customer: body.stripe.customerID, limit: 100
stripe.invoices.list options, (err, invoices) -> stripe.invoices.list options, (err, invoices) ->
expect(err).toBeNull() expect(err).toBeNull()
expect(invoices).not.toBeNull()
return done(updatedUser) unless invoices?
expect(invoices.has_more).toEqual(false) expect(invoices.has_more).toEqual(false)
makeWebhookCall = (invoice) -> makeWebhookCall = (invoice) ->
(callback) -> (callback) ->