mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-30 19:06:59 -05:00
Merge branch 'master' into production
This commit is contained in:
commit
8be5278f98
17 changed files with 541 additions and 41 deletions
|
@ -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')
|
||||||
|
|
|
@ -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 ?= {}
|
||||||
|
|
|
@ -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:"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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: "Победители"
|
||||||
|
|
50
app/styles/admin/analytics-subscriptions.sass
Normal file
50
app/styles/admin/analytics-subscriptions.sass
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
51
app/templates/admin/analytics-subscriptions.jade
Normal file
51
app/templates/admin/analytics-subscriptions.jade
Normal 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
|
|
@ -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..
|
323
app/views/admin/AnalyticsSubscriptionsView.coffee
Normal file
323
app/views/admin/AnalyticsSubscriptionsView.coffee
Normal 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++
|
|
@ -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)
|
||||||
|
|
|
@ -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 =
|
||||||
[
|
[
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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) ->
|
||||||
|
|
Loading…
Reference in a new issue