From 57fb8588b62f3f6291c4b9dce112b86947276610 Mon Sep 17 00:00:00 2001 From: jigsaw Date: Fri, 27 Mar 2015 16:02:04 +0900 Subject: [PATCH 1/9] Translate Archmage page in Japanese --- app/locale/ja.coffee | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/locale/ja.coffee b/app/locale/ja.coffee index dc7f5b85f..a43347c81 100644 --- a/app/locale/ja.coffee +++ b/app/locale/ja.coffee @@ -25,7 +25,7 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese", code: "コード" admin: "管理" # Only shows up when you are an admin home: "ホーム" - contribute: "貢献" + contribute: "コントリビュート" legal: "規約" about: "CoCoについて" contact: "お問い合わせ" @@ -828,24 +828,24 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese", polls: priority: "プライオリティ" -# contribute: -# page_title: "Contributing" -# 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!" + contribute: + page_title: "コントリビュート" + intro_blurb: "CodeCombat は100%オープンソースです!何百もの熱心なプレイヤーが私たちがゲームを作るのを手伝っています。私たちと一緒に CodeCombat の次のチャプターを作って世界中のプレイヤーにプログラミングを教えましょう!" # alert_account_message_intro: "Hey there!" # 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." -# class_attributes: "Class Attributes" -# archmage_attribute_1_pref: "Knowledge in " -# 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_2: "Some experience in programming and personal initiative. We'll help you get oriented, but we can't spend much time training you." -# how_to_join: "How To Join" -# join_desc_1: "Anyone can help out! Just check out our " -# 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_3: ", or find us in our " -# join_desc_4: "and we'll go from there!" -# join_url_email: "Email us" -# join_url_hipchat: "public HipChat room" -# archmage_subscribe_desc: "Get emails on new coding opportunities and announcements." + archmage_introduction: "ゲームを作る上で一番重要なのは、たくさんの要素を合成することです。グラフィック、サウンド、リアルタイムネットワーキング、ソーシャルネットワーキング、一般的なプログラミング、ローレベルのデータベースマネジメント、管理画面のデザインやインターフェイスなど多岐に渡ります。やらなくてはいけないことはたくさんあります。もしあなたが経験豊富なプログラマであればアーキメイジになって CodeCombat のコアにコミットしましょう。ぜひとも私たちの最高のプログラミングゲームを手伝ってください。" + class_attributes: "クラスの属性" + archmage_attribute_1_pref: "" + archmage_attribute_1_suf: " の知識か、それを学ぶ欲求。ほとんどの私たちのコードはこの言語で書かれています。もしあなたが Ruby や Python のファンなら親しく感じるでしょう。JavaScript ですが、より素敵なシンタックスです。" + archmage_attribute_2: "プログラミングの経験や、自ら率先して行動すること。私たちは慣れるのをお手伝いしますが、あなたをトレーニングする時間はありません。" + how_to_join: "参加の方法" + join_desc_1: "誰でも" + join_desc_2: "からはじめることができます。また、下のチェックボックをオンにするとアークメイジと CodeCombat の最新情報がメールで届きます。さらに深く一翼を担いたいですか?" + join_desc_3: "をするか、私たちの" + join_desc_4: "で私たちに連絡してください!" + join_url_email: "メール" + join_url_hipchat: "公開の HipChat のルーム" + 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_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!" @@ -890,7 +890,7 @@ module.exports = nativeDescription: "日本語", englishDescription: "Japanese", # ambassador_subscribe_desc: "Get emails on support updates and multiplayer developments." # changes_auto_save: "Changes are saved automatically when you toggle checkboxes." # diligent_scribes: "Our Diligent Scribes:" -# powerful_archmages: "Our Powerful Archmages:" + powerful_archmages: "私たちの強力なアークメイジたち:" # creative_artisans: "Our Creative Artisans:" # brave_adventurers: "Our Brave Adventurers:" # translating_diplomats: "Our Translating Diplomats:" From a7198d54563b7c1113bf99d3cafe46411864752c Mon Sep 17 00:00:00 2001 From: Imperadeiro98 Date: Fri, 27 Mar 2015 13:57:53 +0000 Subject: [PATCH 2/9] Update pt-PT.coffee --- app/locale/pt-PT.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/locale/pt-PT.coffee b/app/locale/pt-PT.coffee index 6fd425f82..dfc46c0c6 100644 --- a/app/locale/pt-PT.coffee +++ b/app/locale/pt-PT.coffee @@ -941,13 +941,13 @@ module.exports = nativeDescription: "Português (Portugal)", englishDescription: fight: "Lutar!" watch_victory: "Vê a tua vitória" defeat_the: "Derrota o" -# tournament_started: ", started" + tournament_started: ", começou" tournament_ends: "O Torneio acaba" tournament_ended: "O Torneio acabou" 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_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" rules: "Regras" winners: "Vencedores" From b2c8f3c1f6ba3a8db29c1fdffbf6e7bcf6b8104a Mon Sep 17 00:00:00 2001 From: nixel2007 Date: Fri, 27 Mar 2015 17:23:11 +0300 Subject: [PATCH 3/9] Update ru.coffee. Zero_sum. --- app/locale/ru.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/locale/ru.coffee b/app/locale/ru.coffee index 314a13474..3d43b6683 100644 --- a/app/locale/ru.coffee +++ b/app/locale/ru.coffee @@ -941,13 +941,13 @@ module.exports = nativeDescription: "русский", englishDescription: "Russi fight: "В бой!" watch_victory: "Наблюдать за победой" defeat_the: "Победить" -# tournament_started: ", started" + tournament_started: ", начат" tournament_ends: "Турнир заканчивается" tournament_ended: "Турнир закончился" tournament_rules: "Правила турнира" tournament_blurb: "Пишите код, собирайте золото, стройте армию, крушите противников, получайте призы и улучшайте вашу карьеру в нашем \"$40,000 турнире жадности\"! Узнайте больше" 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: "в нашем блоге" rules: "Правила" winners: "Победители" From 3137eb05f09f946dbce1692022c27ecfda501c44 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Fri, 27 Mar 2015 11:50:08 -0700 Subject: [PATCH 4/9] Fix for extra-wide Zero Sum graphic in level info popover. --- app/styles/play/campaign-view.sass | 1 + 1 file changed, 1 insertion(+) diff --git a/app/styles/play/campaign-view.sass b/app/styles/play/campaign-view.sass index 813500d51..486dc038f 100644 --- a/app/styles/play/campaign-view.sass +++ b/app/styles/play/campaign-view.sass @@ -225,6 +225,7 @@ $gameControlMargin: 30px img display: block margin: 0px auto + max-width: 100% .level-status background: transparent url(/images/pages/play/level-info-status-spritesheet.png) no-repeat 0 0 From 155eb4a4a8c18513e222d8e96a7ac5127bd06f88 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Fri, 27 Mar 2015 14:47:59 -0700 Subject: [PATCH 5/9] Fixed missing disabled success button illustrated image reference. --- app/styles/common/common.sass | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/styles/common/common.sass b/app/styles/common/common.sass index 98f9ffee6..77762321d 100644 --- a/app/styles/common/common.sass +++ b/app/styles/common/common.sass @@ -299,7 +299,7 @@ kbd &.btn-primary border-image-source: url(/images/common/button-background-primary-disabled-border.png) &.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 border-image-source: url(/images/common/button-background-warning-disabled-border.png) &.btn-danger @@ -357,7 +357,7 @@ html.no-borderimage &.btn-primary background-image: url(/images/common/button-background-primary-disabled.png) &.btn-success - background-image: url(/images/common/button-background-success-disabled.png) + background-image: url(/images/common/button-background-success-inactive.png) &.btn-warning background-image: url(/images/common/button-background-warning-disabled.png) &.btn-danger From 53de6c6134de821945f74246fb1a7bab9bb9d981 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Fri, 27 Mar 2015 11:22:21 -0700 Subject: [PATCH 6/9] Add current subscription counts view under the admin page --- app/core/Router.coffee | 3 +- app/styles/admin/analytics-subscriptions.sass | 19 ++++++ app/templates/admin.jade | 8 ++- .../admin/analytics-subscriptions.jade | 35 ++++++++++ .../{growth.jade => analytics-users.jade} | 2 +- .../admin/AnalyticsSubscriptionsView.coffee | 53 +++++++++++++++ ...hView.coffee => AnalyticsUsersView.coffee} | 21 +++--- scripts/analytics/stripeSubscribers.js | 2 +- server/commons/mapping.coffee | 1 + server/payments/subscription_handler.coffee | 65 +++++++++++++++++++ .../functional/subscription.spec.coffee | 2 + 11 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 app/styles/admin/analytics-subscriptions.sass create mode 100644 app/templates/admin/analytics-subscriptions.jade rename app/templates/admin/{growth.jade => analytics-users.jade} (94%) create mode 100644 app/views/admin/AnalyticsSubscriptionsView.coffee rename app/views/admin/{GrowthView.coffee => AnalyticsUsersView.coffee} (95%) diff --git a/app/core/Router.coffee b/app/core/Router.coffee index bbf2b4f4a..29ad86fb2 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -33,7 +33,8 @@ module.exports = class CocoRouter extends Backbone.Router 'admin/clas': go('admin/CLAsView') 'admin/employers': go('admin/EmployersListView') '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/users': go('admin/UsersView') 'admin/base': go('admin/BaseView') diff --git a/app/styles/admin/analytics-subscriptions.sass b/app/styles/admin/analytics-subscriptions.sass new file mode 100644 index 000000000..34206b2ca --- /dev/null +++ b/app/styles/admin/analytics-subscriptions.sass @@ -0,0 +1,19 @@ +#admin-analytics-subscriptions-view + + .total-count + width: 33% + float: left + color: green + .remaining-count + width: 33% + float: left + color: blue + .cancelled-count + width: 33% + float: left + color: red + + .count + font-size: 50pt + .description + font-size: 8pt diff --git a/app/templates/admin.jade b/app/templates/admin.jade index 2887717a6..ebf3cbc14 100644 --- a/app/templates/admin.jade +++ b/app/templates/admin.jade @@ -44,8 +44,12 @@ block content li a(href="/admin/clas", data-i18n="admin.clas") CLAs if me.isAdmin() - li - a(href="/admin/growth", data-i18n="admin.growth") Growth + li Analytics + ul + li + a(href="/admin/analytics/subscriptions") Subscriptions + li + a(href="/admin/analytics/users") Users (needs updating) if me.isAdmin() hr diff --git a/app/templates/admin/analytics-subscriptions.jade b/app/templates/admin/analytics-subscriptions.jade new file mode 100644 index 000000000..7369adf42 --- /dev/null +++ b/app/templates/admin/analytics-subscriptions.jade @@ -0,0 +1,35 @@ +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 + .total-count + div.description Total + div.count= total + .remaining-count + div.description Remaining + div.count= total - cancelled + .cancelled-count + div.description cancelled + div.count= cancelled + + 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 diff --git a/app/templates/admin/growth.jade b/app/templates/admin/analytics-users.jade similarity index 94% rename from app/templates/admin/growth.jade rename to app/templates/admin/analytics-users.jade index 39abf670a..982281a24 100644 --- a/app/templates/admin/growth.jade +++ b/app/templates/admin/analytics-users.jade @@ -2,7 +2,7 @@ extends /templates/base block content - h1(data-i18n="admin.growth_title") Growth + h1(data-i18n="admin.growth_title") Users if me.isAdmin() if crunchingData h4 Crunching Data.. diff --git a/app/views/admin/AnalyticsSubscriptionsView.coffee b/app/views/admin/AnalyticsSubscriptionsView.coffee new file mode 100644 index 000000000..5f905da48 --- /dev/null +++ b/app/views/admin/AnalyticsSubscriptionsView.coffee @@ -0,0 +1,53 @@ +RootView = require 'views/core/RootView' +template = require 'templates/admin/analytics-subscriptions' +RealTimeCollection = require 'collections/RealTimeCollection' + +require 'vendor/d3' + +module.exports = class AnalyticsSubscriptionsView extends RootView + id: 'admin-analytics-subscriptions-view' + template: template + + constructor: (options) -> + super options + @refreshData() + + getRenderData: -> + context = super() + context.subs = @subs ? [] + context.total = @total ? 0 + context.cancelled = @cancelled ? 0 + context + + refreshData: -> + @subs = [] + @total = 0 + @cancelled = 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) + + for i in [@subs.length - 1..0] + @total += @subs[i].started + @cancelled += @subs[i].cancelled + @subs[i].total = @total + @render() + @supermodel.addRequestResource('subscriptions', { + url: '/db/subscription/-/subscriptions' + method: 'GET' + success: onSuccess + }, 0).load() diff --git a/app/views/admin/GrowthView.coffee b/app/views/admin/AnalyticsUsersView.coffee similarity index 95% rename from app/views/admin/GrowthView.coffee rename to app/views/admin/AnalyticsUsersView.coffee index 1bc5957ab..063a8f6ab 100644 --- a/app/views/admin/GrowthView.coffee +++ b/app/views/admin/AnalyticsUsersView.coffee @@ -1,5 +1,5 @@ RootView = require 'views/core/RootView' -template = require 'templates/admin/growth' +template = require 'templates/admin/analytics-users' RealTimeCollection = require 'collections/RealTimeCollection' require 'vendor/d3' @@ -16,8 +16,8 @@ require 'vendor/d3' # TODO: aggregate recent data if missing? # -module.exports = class GrowthView extends RootView - id: 'admin-growth-view' +module.exports = class AnalyticsUsersView extends RootView + id: 'admin-analytics-users-view' template: template height: 300 width: 1000 @@ -55,7 +55,7 @@ module.exports = class GrowthView extends RootView if me.isAdmin() @createPerDayChart() @createPerMonthChart() - + createPerDayChart: -> addedData = [] totalData = [] @@ -76,7 +76,7 @@ module.exports = class GrowthView extends RootView createLineChart: (selector, data, guidelineSpacing, sevenDayAverage=false) -> return unless data.length > 1 - + minVal = d3.min(data, (d) -> d.value) maxVal = d3.max(data, (d) -> d.value) @@ -129,16 +129,16 @@ module.exports = class GrowthView extends RootView .attr("cx", (d) -> d.x ) .attr("cy", (d) -> d.y ) .attr("r", "2px") - .attr("fill", "black") - + .attr("fill", "black") + chart.selectAll(".text") .data(points) .enter() .append("text") .attr("dy", ".35em") .attr("transform", (d, i) => "translate(" + d.x + "," + @height + ") rotate(270)") - .text((d) -> - if d.id.length is 8 + .text((d) -> + if d.id.length is 8 return "#{parseInt(d.id[4..5])}/#{parseInt(d.id[6..7])}/#{d.id[0..3]}" else return "#{parseInt(d.id[4..5])}/#{d.id[0..3]}" @@ -161,7 +161,7 @@ module.exports = class GrowthView extends RootView .attr("cx", (d) -> d.x ) .attr("cy", (d) -> d.y ) .attr("r", "2px") - .attr("fill", "purple") + .attr("fill", "purple") chart.selectAll('.line') .data(sevenLinks) @@ -191,4 +191,3 @@ module.exports = class GrowthView extends RootView .attr("y", (d) -> d.start.y - 6) .attr("dy", ".35em") .text((d) -> d.start.id) - diff --git a/scripts/analytics/stripeSubscribers.js b/scripts/analytics/stripeSubscribers.js index a2db34029..5822518df 100644 --- a/scripts/analytics/stripeSubscribers.js +++ b/scripts/analytics/stripeSubscribers.js @@ -29,7 +29,7 @@ getSubscriptions(null, function () { }); -function getSubscriptions(starting_after, done) +function getSubscriptions(starting_after, done) { var options = {limit: 100}; if (starting_after) options.starting_after = starting_after; diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index 8c7e333cc..40a5e9a9e 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -24,6 +24,7 @@ module.exports.handlers = 'poll': 'polls/poll_handler' 'user_polls_record': 'polls/user_polls_record_handler' 'prepaid': 'prepaids/prepaid_handler' + 'subscription': 'payments/subscription_handler' module.exports.routes = [ diff --git a/server/payments/subscription_handler.coffee b/server/payments/subscription_handler.coffee index fb3df7f51..9db6f8780 100644 --- a/server/payments/subscription_handler.coffee +++ b/server/payments/subscription_handler.coffee @@ -2,6 +2,7 @@ # the stripe property in the user with what's being stored in Stripe. async = require 'async' +config = require '../../server_config' Handler = require '../commons/Handler' discountHandler = require './discount_handler' Prepaid = require '../prepaids/Prepaid' @@ -21,6 +22,70 @@ class SubscriptionHandler extends Handler logSubscriptionError: (user, 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) + + 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) -> if (not req.user) or req.user.isAnonymous() or user.isAnonymous() return done({res: 'You must be signed in to subscribe.', code: 403}) diff --git a/test/server/functional/subscription.spec.coffee b/test/server/functional/subscription.spec.coffee index c15fd3164..dc1cc55e2 100644 --- a/test/server/functional/subscription.spec.coffee +++ b/test/server/functional/subscription.spec.coffee @@ -419,6 +419,8 @@ describe 'Subscriptions', -> options = customer: body.stripe.customerID, limit: 100 stripe.invoices.list options, (err, invoices) -> expect(err).toBeNull() + expect(invoices).not.toBeNull() + return done(updatedUser) unless invoices? expect(invoices.has_more).toEqual(false) makeWebhookCall = (invoice) -> (callback) -> From 1dbb38231849860ec9618947a4c219abb221f723 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Fri, 27 Mar 2015 11:39:45 -0700 Subject: [PATCH 7/9] Add auto-refresh to sub counts admin page --- app/views/admin/AnalyticsSubscriptionsView.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/admin/AnalyticsSubscriptionsView.coffee b/app/views/admin/AnalyticsSubscriptionsView.coffee index 5f905da48..886dda4e8 100644 --- a/app/views/admin/AnalyticsSubscriptionsView.coffee +++ b/app/views/admin/AnalyticsSubscriptionsView.coffee @@ -10,7 +10,9 @@ module.exports = class AnalyticsSubscriptionsView extends RootView constructor: (options) -> super options - @refreshData() + if me.isAdmin() + @refreshData() + _.delay (=> @refreshData()), 30 * 60 * 1000 getRenderData: -> context = super() @@ -20,6 +22,7 @@ module.exports = class AnalyticsSubscriptionsView extends RootView context refreshData: -> + return unless me.isAdmin() @subs = [] @total = 0 @cancelled = 0 From 532e8133c6c5cbb3e51b9a28dc762e4cd3849c09 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Fri, 27 Mar 2015 15:27:22 -0700 Subject: [PATCH 8/9] Add graph to sub counts admin page --- app/styles/admin/analytics-subscriptions.sass | 43 ++- .../admin/analytics-subscriptions.jade | 24 +- .../admin/AnalyticsSubscriptionsView.coffee | 277 +++++++++++++++++- server/payments/subscription_handler.coffee | 3 +- 4 files changed, 331 insertions(+), 16 deletions(-) diff --git a/app/styles/admin/analytics-subscriptions.sass b/app/styles/admin/analytics-subscriptions.sass index 34206b2ca..b4a6eb0af 100644 --- a/app/styles/admin/analytics-subscriptions.sass +++ b/app/styles/admin/analytics-subscriptions.sass @@ -1,19 +1,50 @@ #admin-analytics-subscriptions-view - .total-count - width: 33% + .big-stat + width: 25% float: left + + .total-count color: green .remaining-count - width: 33% - float: left color: blue .cancelled-count - width: 33% - float: left color: red + .churn-count + color: orange .count font-size: 50pt .description font-size: 8pt + + .line-graph-label + font-size: 10pt + font-weight: normal + .line-graph-container + height: 500px + width: 100% + + // TODO: figure out why this is necessary + margin-bottom: 100px + + .x.axis + font-size: 9pt + path + display: none + .y.axis + font-size: 9pt + path + display: none + .key-line + font-size: 9pt + .key-text + font-size: 9pt + .graph-point-info-container + display: none + position: absolute + padding: 10px + border: 1px solid black + z-index: 3 + background-color: blanchedalmond + font-size: 10pt diff --git a/app/templates/admin/analytics-subscriptions.jade b/app/templates/admin/analytics-subscriptions.jade index 7369adf42..b1887b007 100644 --- a/app/templates/admin/analytics-subscriptions.jade +++ b/app/templates/admin/analytics-subscriptions.jade @@ -9,16 +9,32 @@ block content h1 Fetching subscriptions data... else div - .total-count + .big-stat.total-count div.description Total div.count= total - .remaining-count + .big-stat.remaining-count div.description Remaining div.count= total - cancelled - .cancelled-count - div.description cancelled + .big-stat.cancelled-count + div.description Cancelled div.count= cancelled + .big-stat.churn-count + div.description Monthly Churn + div.count #{monthlyChurn.toFixed(2)}% + each graph in analytics.graphs + .line-graph-container + each line in graph.lines + each point in line.points + .graph-point-info-container(data-pointid="#{point.pointID}") + div(style='font-weight:bold;') #{point.day} + each value in point.values + div #{value} + + div *Stripe APIs do not return information about inactive subs. + + br + table.table.table-condensed.concepts-table thead tr diff --git a/app/views/admin/AnalyticsSubscriptionsView.coffee b/app/views/admin/AnalyticsSubscriptionsView.coffee index 886dda4e8..b1f60ae65 100644 --- a/app/views/admin/AnalyticsSubscriptionsView.coffee +++ b/app/views/admin/AnalyticsSubscriptionsView.coffee @@ -2,6 +2,10 @@ RootView = require 'views/core/RootView' template = require 'templates/admin/analytics-subscriptions' RealTimeCollection = require 'collections/RealTimeCollection' +# TODO: Add revenue line +# TODO: Add LTV line +# TODO: Graphing code copied/mangled from campaign editor level view. OMG, DRY. + require 'vendor/d3' module.exports = class AnalyticsSubscriptionsView extends RootView @@ -16,16 +20,24 @@ module.exports = class AnalyticsSubscriptionsView extends RootView getRenderData: -> context = super() + context.analytics = @analytics context.subs = @subs ? [] context.total = @total ? 0 context.cancelled = @cancelled ? 0 + context.monthlyChurn = @monthlyChurn ? 0.0 context + afterRender: -> + super() + @updateAnalyticsGraphs() + refreshData: -> return unless me.isAdmin() + @analytics = graphs: [] @subs = [] @total = 0 @cancelled = 0 + @monthlyChurn = 0.0 onSuccess = (subs) => subDayMap = {} for sub in subs @@ -42,15 +54,270 @@ module.exports = class AnalyticsSubscriptionsView extends RootView day: day started: subDayMap[day]['start'] cancelled: subDayMap[day]['cancel'] or 0 - @subs.sort (a, b) -> -a.day.localeCompare(b.day) - for i in [@subs.length - 1..0] - @total += @subs[i].started - @cancelled += @subs[i].cancelled - @subs[i].total = @total + @subs.sort (a, b) -> a.day.localeCompare(b.day) + startedLastMonth = 0 + for sub, i in @subs + @total += sub.started + @cancelled += sub.cancelled + sub.total = @total + startedLastMonth += sub.started if @subs.length - i < 31 + @monthlyChurn = @cancelled / startedLastMonth * 100.0 + + @updateAnalyticsGraphData() @render() @supermodel.addRequestResource('subscriptions', { url: '/db/subscription/-/subscriptions' method: 'GET' success: onSuccess }, 0).load() + + + updateAnalyticsGraphData: -> + # console.log 'updateAnalyticsGraphData' + # Build graphs based on available @analytics data + # Currently only one graph + @analytics.graphs = [graphID: 'total-subs', lines: []] + + return unless @subs?.length > 0 + + # TODO: Where should this metadata live? + # TODO: lineIDs assumed to be unique across graphs + totalSubsID = 'total-subs' + startedSubsID = 'started-subs' + cancelledSubsID = 'cancelled-subs' + lineMetadata = {} + lineMetadata[totalSubsID] = + description: 'Total Active Subscriptions' + color: 'green' + lineMetadata[startedSubsID] = + description: 'New Subscriptions' + color: 'blue' + lineMetadata[cancelledSubsID] = + description: 'Cancelled Subscriptions' + color: 'red' + + days = (sub.day for sub in @subs) + if days.length > 0 + currentIndex = 0 + currentDay = days[currentIndex] + currentDate = new Date(currentDay + "T00:00:00.000Z") + lastDay = days[days.length - 1] + while currentDay isnt lastDay + days.splice currentIndex, 0, currentDay if days[currentIndex] isnt currentDay + currentIndex++ + currentDate.setUTCDate(currentDate.getUTCDate() + 1) + currentDay = currentDate.toISOString().substr(0, 10) + + ## Totals + + # Build line data + levelPoints = [] + for sub, i in @subs + levelPoints.push + x: i + y: sub.total + day: sub.day + pointID: "#{totalSubsID}#{i}" + values: [] + + # Ensure points for each day + for day, i in days + if levelPoints.length <= i or levelPoints[i].day isnt day + prevY = if i > 0 then levelPoints[i - 1].y else 0.0 + levelPoints.splice i, 0, + y: prevY + day: day + values: [] + levelPoints[i].x = i + levelPoints[i].pointID = "#{totalSubsID}#{i}" + + levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60 + + @analytics.graphs[0].lines.push + lineID: totalSubsID + enabled: true + points: levelPoints + description: lineMetadata[totalSubsID].description + lineColor: lineMetadata[totalSubsID].color + min: 0 + max: d3.max(@subs, (d) -> d.total) + + ## Started + + # Build line data + levelPoints = [] + for sub, i in @subs + levelPoints.push + x: i + y: sub.started + day: sub.day + pointID: "#{startedSubsID}#{i}" + values: [] + + # Ensure points for each day + for day, i in days + if levelPoints.length <= i or levelPoints[i].day isnt day + prevY = if i > 0 then levelPoints[i - 1].y else 0.0 + levelPoints.splice i, 0, + y: prevY + day: day + values: [] + levelPoints[i].x = i + levelPoints[i].pointID = "#{startedSubsID}#{i}" + + levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60 + + @analytics.graphs[0].lines.push + lineID: startedSubsID + enabled: true + points: levelPoints + description: lineMetadata[startedSubsID].description + lineColor: lineMetadata[startedSubsID].color + min: 0 + max: d3.max(@subs, (d) -> d.started) + + ## Cancelled + + # Build line data + levelPoints = [] + for sub, i in @subs + levelPoints.push + x: i + y: sub.cancelled + day: sub.day + pointID: "#{cancelledSubsID}#{i}" + values: [] + + # Ensure points for each day + for day, i in days + if levelPoints.length <= i or levelPoints[i].day isnt day + prevY = if i > 0 then levelPoints[i - 1].y else 0.0 + levelPoints.splice i, 0, + y: prevY + day: day + values: [] + levelPoints[i].x = i + levelPoints[i].pointID = "#{cancelledSubsID}#{i}" + + levelPoints.splice(0, levelPoints.length - 60) if levelPoints.length > 60 + + @analytics.graphs[0].lines.push + lineID: cancelledSubsID + enabled: true + points: levelPoints + description: lineMetadata[cancelledSubsID].description + lineColor: lineMetadata[cancelledSubsID].color + min: 0 + max: d3.max(@subs, (d) -> d.started) + + updateAnalyticsGraphs: -> + # Build d3 graphs + return unless @analytics?.graphs?.length > 0 + containerSelector = '.line-graph-container' + # console.log 'updateAnalyticsGraphs', containerSelector, @analytics.graphs + + margin = 20 + keyHeight = 20 + xAxisHeight = 20 + yAxisWidth = 40 + containerWidth = $(containerSelector).width() + containerHeight = $(containerSelector).height() + + for graph in @analytics.graphs + graphLineCount = _.reduce graph.lines, ((sum, item) -> if item.enabled then sum + 1 else sum), 0 + svg = d3.select(containerSelector).append("svg") + .attr("width", containerWidth) + .attr("height", containerHeight) + width = containerWidth - margin * 2 - yAxisWidth * graphLineCount + height = containerHeight - margin * 2 - xAxisHeight - keyHeight * graphLineCount + currentLine = 0 + for line in graph.lines + continue unless line.enabled + xRange = d3.scale.linear().range([0, width]).domain([d3.min(line.points, (d) -> d.x), d3.max(line.points, (d) -> d.x)]) + yRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max]) + + # x-Axis and guideline once + if currentLine is 0 + startDay = new Date(line.points[0].day) + endDay = new Date(line.points[line.points.length - 1].day) + xAxisRange = d3.time.scale() + .domain([startDay, endDay]) + .range([0, width]) + xAxis = d3.svg.axis() + .scale(xAxisRange) + svg.append("g") + .attr("class", "x axis") + .call(xAxis) + .selectAll("text") + .attr("dy", ".35em") + .attr("transform", "translate(" + (margin + yAxisWidth * (graphLineCount - 1)) + "," + (height + margin) + ")") + .style("text-anchor", "start") + + # Horizontal guidelines + # svg.selectAll(".line") + # .data([10, 30, 50, 70, 90]) + # .enter() + # .append("line") + # .attr("x1", margin + yAxisWidth * graphLineCount) + # .attr("y1", (d) -> margin + yRange(d)) + # .attr("x2", margin + yAxisWidth * graphLineCount + width) + # .attr("y2", (d) -> margin + yRange(d)) + # .attr("stroke", line.lineColor) + # .style("opacity", "0.5") + + # y-Axis + yAxisRange = d3.scale.linear().range([height, 0]).domain([line.min, line.max]) + yAxis = d3.svg.axis() + .scale(yRange) + .orient("left") + svg.append("g") + .attr("class", "y axis") + .attr("transform", "translate(" + (margin + yAxisWidth * currentLine) + "," + margin + ")") + .style("color", line.lineColor) + .call(yAxis) + .selectAll("text") + .attr("y", 0) + .attr("x", 0) + .attr("fill", line.lineColor) + .style("text-anchor", "start") + + # Key + svg.append("line") + .attr("x1", margin) + .attr("y1", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2) + .attr("x2", margin + 40) + .attr("y2", margin + height + xAxisHeight + keyHeight * currentLine + keyHeight / 2) + .attr("stroke", line.lineColor) + .attr("class", "key-line") + svg.append("text") + .attr("x", margin + 40 + 10) + .attr("y", margin + height + xAxisHeight + keyHeight * currentLine + (keyHeight + 10) / 2) + .attr("fill", line.lineColor) + .attr("class", "key-text") + .text(line.description) + + # Path and points + svg.selectAll(".circle") + .data(line.points) + .enter() + .append("circle") + .attr("transform", "translate(" + (margin + yAxisWidth * graphLineCount) + "," + margin + ")") + .attr("cx", (d) -> xRange(d.x)) + .attr("cy", (d) -> yRange(d.y)) + .attr("r", 2) + .attr("fill", line.lineColor) + .attr("stroke-width", 1) + .attr("class", "graph-point") + .attr("data-pointid", (d) -> "#{line.lineID}#{d.x}") + d3line = d3.svg.line() + .x((d) -> xRange(d.x)) + .y((d) -> yRange(d.y)) + .interpolate("linear") + svg.append("path") + .attr("d", d3line(line.points)) + .attr("transform", "translate(" + (margin + yAxisWidth * graphLineCount) + "," + margin + ")") + .style("stroke-width", 1) + .style("stroke", line.lineColor) + .style("fill", "none") + currentLine++ diff --git a/server/payments/subscription_handler.coffee b/server/payments/subscription_handler.coffee index 9db6f8780..79005ea0f 100644 --- a/server/payments/subscription_handler.coffee +++ b/server/payments/subscription_handler.coffee @@ -37,8 +37,9 @@ class SubscriptionHandler extends Handler return @sendForbiddenError(res) unless req.user and req.user.isAdmin() - @subs ?= [] + # @subs ?= [] # return @sendSuccess(res, @subs) unless _.isEmpty(@subs) + @subs = [] customersProcessed = 0 nextBatch = (starting_after, done) => From 37d7b4661cc2a7a342cc63f7714bd39f0d2bd606 Mon Sep 17 00:00:00 2001 From: Nick Winter Date: Fri, 27 Mar 2015 16:24:40 -0700 Subject: [PATCH 9/9] Possible fix for #2589, but need to test in production. --- app/lib/LevelLoader.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee index 1e5519d97..5b932bb80 100644 --- a/app/lib/LevelLoader.coffee +++ b/app/lib/LevelLoader.coffee @@ -62,6 +62,9 @@ module.exports = class LevelLoader extends CocoClass # Session Loading loadSession: -> + if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] + @sessionDependenciesRegistered = {} + if @sessionID url = "/db/level.session/#{@sessionID}" else @@ -103,7 +106,6 @@ module.exports = class LevelLoader extends CocoClass else if session is @opponentSession @consolidateFlagHistory() if @session.loaded return unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop'] - @sessionDependenciesRegistered ?= {} heroConfig = session.get('heroConfig') heroConfig ?= me.get('heroConfig') if session is @session and not @headless heroConfig ?= {}