├── abtest-both.coffee ├── drelease ├── .gitignore ├── package.js ├── abtest-server.coffee ├── .versions ├── abtest-dashboard.html ├── abtest-client.coffee ├── README.md └── abtest-dashboard.coffee /abtest-both.coffee: -------------------------------------------------------------------------------- 1 | @ABTests = new Mongo.Collection("abtests") -------------------------------------------------------------------------------- /drelease: -------------------------------------------------------------------------------- 1 | git add . 2 | git commit -am "Releasing" 3 | mrt release . 4 | git push --tags -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.meteor/local 2 | /.git 3 | /.build 4 | /packages 5 | /c 6 | /p 7 | /*.iml 8 | /.idea 9 | /smart.lock -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: "manuel:abtest", 3 | summary: "Simple AB testing framework for Meteor.", 4 | version: "1.1.0", 5 | git: "https://github.com/ManuelDeLeon/meteor-abtest" 6 | }); 7 | 8 | Package.onUse(function(api) { 9 | api.versionsFrom("METEOR@1.0"); 10 | api.use("mongo"); 11 | api.use("coffeescript", ["client", "server"]); 12 | api.use("templating", "client"); 13 | 14 | api.addFiles(["abtest-client.coffee"], "client"); 15 | api.addFiles(["abtest-dashboard.coffee", "abtest-dashboard.html"], "client"); 16 | api.addFiles(["abtest-both.coffee"], ["client", "server"]); 17 | api.addFiles(["abtest-server.coffee"], "server"); 18 | 19 | api.export("ABTest"); 20 | api.export("ABTestServer"); 21 | }); 22 | -------------------------------------------------------------------------------- /abtest-server.coffee: -------------------------------------------------------------------------------- 1 | class ABTestServer 2 | @adminIds = [] 3 | 4 | Meteor.publish "ABTests", -> 5 | if ABTestServer.adminIds is '*' or this.userId in ABTestServer.adminIds 6 | ABTests.find() 7 | 8 | Meteor.methods 9 | startAbTest: (name, value, order) -> 10 | increment = {} 11 | increment["values.#{value}.finished"] = 0 12 | increment["values.#{value}.started"] = 1 13 | rank = {} 14 | rank["values.#{value}.rank"] = order 15 | 16 | ABTests.update { name: name }, { $set: rank, $inc: increment }, { upsert: true } 17 | 18 | finishAbTest: (name, value) -> 19 | increment = {} 20 | increment["values.#{value}.finished"] = 1 21 | ABTests.update { name: name }, { $inc: increment } 22 | 23 | resetAbTest: (name) -> 24 | if ABTestServer.adminIds is '*' or Meteor.userId() in ABTestServer.adminIds 25 | ABTests.remove { name: name } -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | allow-deny@1.0.6 2 | babel-compiler@6.19.4 3 | babel-runtime@1.0.1 4 | base64@1.0.10 5 | binary-heap@1.0.10 6 | blaze@2.3.2 7 | blaze-tools@1.0.10 8 | boilerplate-generator@1.1.1 9 | caching-compiler@1.1.9 10 | callback-hook@1.0.10 11 | check@1.2.5 12 | coffeescript@1.0.17 13 | ddp@1.3.0 14 | ddp-client@2.0.0 15 | ddp-common@1.2.9 16 | ddp-server@2.0.0 17 | deps@1.0.12 18 | diff-sequence@1.0.7 19 | ecmascript@0.8.2 20 | ecmascript-runtime@0.4.1 21 | ecmascript-runtime-client@0.4.3 22 | ecmascript-runtime-server@0.4.1 23 | ejson@1.0.13 24 | geojson-utils@1.0.10 25 | html-tools@1.0.11 26 | htmljs@1.0.11 27 | id-map@1.0.9 28 | jquery@1.11.10 29 | logging@1.1.17 30 | manuel:abtest@1.1.0 31 | meteor@1.7.1 32 | minimongo@1.2.1 33 | modules@0.9.4 34 | modules-runtime@0.8.0 35 | mongo@1.1.22 36 | mongo-id@1.0.6 37 | npm-mongo@2.2.30 38 | observe-sequence@1.0.16 39 | ordered-dict@1.0.9 40 | promise@0.8.9 41 | random@1.0.10 42 | reactive-var@1.0.11 43 | retry@1.0.9 44 | routepolicy@1.0.12 45 | spacebars@1.0.15 46 | spacebars-compiler@1.1.3 47 | templating@1.0.11 48 | tracker@1.1.3 49 | ui@1.0.13 50 | underscore@1.0.10 51 | webapp@1.3.17 52 | webapp-hashing@1.0.9 53 | -------------------------------------------------------------------------------- /abtest-dashboard.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /abtest-client.coffee: -------------------------------------------------------------------------------- 1 | class ABTest 2 | randomNumber = (min, max) -> Math.floor(Math.random() * (max - min + 1) + min ) 3 | weightedRand = (spec) -> 4 | table = [] 5 | for i of spec 6 | j = 0 7 | while j < spec[i] 8 | table.push i 9 | j++ 10 | -> 11 | table[Math.floor(Math.random() * table.length)] 12 | 13 | cookies = null 14 | getCookie = (name) -> 15 | if not cookies 16 | cookies = {} 17 | for cookie in document.cookie.split('; ') 18 | pair = cookie.split('=') 19 | cookies[pair[0]] = pair[1] 20 | return cookies[name] 21 | 22 | setCookie = (name, value, days) -> 23 | if days 24 | date = new Date() 25 | date.setTime date.getTime() + (days * 24 * 60 * 60 * 1000) 26 | expires = "; expires=" + date.toGMTString() 27 | else 28 | expires = "" 29 | document.cookie = name + "=" + value + expires + "; path=/" 30 | return 31 | 32 | getLocalStorage = (name) -> 33 | if localStorage 34 | localStorage.getItem(name) 35 | 36 | setLocalStorage = (name, value) -> 37 | if localStorage 38 | localStorage.setItem name, value 39 | getLocalStorage(name) 40 | 41 | getValue = (name) -> getLocalStorage(name) || getCookie(name) || Session.get(name) 42 | setValue = (name, value) -> setLocalStorage(name, value) || setCookie(name, value, 5000) || Session.set(name, value) 43 | 44 | removeValue = (name) -> 45 | localStorage.removeItem name if localStorage 46 | setCookie name, '', -1 47 | Session.set(name, undefined) 48 | 49 | storageName = (name) -> "abtest-#{name}" 50 | 51 | @start = (name, values) -> 52 | value = getValue storageName(name) 53 | if not value 54 | if values instanceof Array 55 | randomIndex = randomNumber(0, values.length - 1) 56 | value = values[randomIndex] 57 | else 58 | value = weightedRand(values)() 59 | setValue storageName(name), value 60 | Meteor.call 'startAbTest', name, value, randomIndex 61 | value 62 | 63 | @finish = (name) -> 64 | value = getValue storageName(name) 65 | if value 66 | Meteor.call 'finishAbTest', name, value 67 | removeValue name 68 | 69 | @reset = (name) -> Meteor.call 'resetAbTest', name -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | meteor-abtest 2 | ============= 3 | 4 | Simple AB testing framework for Meteor (modeled after Rails' split). 5 | 6 | Usage 7 | ----- 8 | 9 | Install: 10 | ``` 11 | $ meteor add manuel:abtest 12 | ``` 13 | 14 | Return one of the alternatives to be used: 15 | ``` 16 | ABTest.start("Test Name", ['Alternative 1', 'Alternative 2', 'Alternative n']) 17 | ``` 18 | 19 | Return one of the weighted alternatives to be used: 20 | ``` 21 | ABTest.start("Test Name", { 22 | 'Alternative 1': 70, // Show 70% of the time 23 | 'Alternative 2': 20, 24 | 'Alternative 3': 10 25 | }) 26 | ``` 27 | 28 | Conclude the test for this user: 29 | ``` 30 | ABTest.finish("Test Name") 31 | ``` 32 | 33 | 34 | **Example** 35 | ``` 36 | Template.landing.helpers 37 | showRoundButton: -> ABTest.start('Landing Button', ['Normal', 'Round']) is 'Round' 38 | ``` 39 | ``` 40 | Template.landing.events 41 | 'click button': -> ABTest.finish('Landing Button') 42 | ``` 43 | 44 | Displaying the info 45 | ------------------- 46 | 47 | ***On the Client*** 48 | 49 | ``` 50 | {{> abtests }} 51 | ``` 52 | 53 | ***On the Server*** 54 | 55 | Specify the Meteor IDs of the users who have permission to view the data. 56 | ``` 57 | ABTestServer.adminIds = ['user 1 id', 'user 2 id', 'user n id'] 58 | ``` 59 | 60 | ***Example*** 61 | 62 | ![meteor-abtest](https://cloud.githubusercontent.com/assets/4257750/2920902/9cfde158-d6ec-11e3-9ec1-a424378970b3.png) 63 | 64 | Tracking Users 65 | -------------- 66 | This library uses localStorage to keep track of the users. If localStorage is not available it uses cookies and if that fails it uses a session variable. 67 | 68 | ABTests collection 69 | ------------------ 70 | You'll probably never need to do this but if you want you can query the ABTests collection. 71 | 72 | ***ABTests.find() Example*** 73 | 74 | ``` 75 | { 76 | "_id" : "536a9a2c6935af1b3f0eec6d", 77 | "name" : "Welcome Message", 78 | "values" : { 79 | "Join Now" : { 80 | "started" : 15051, 81 | "finished" : 3827 82 | }, 83 | "Get Started" : { 84 | "started" : 14984, 85 | "finished" : 3583 86 | } 87 | } 88 | } 89 | { 90 | "_id" : "536bd11553e89d2e75bd3cda", 91 | "name" : "Landing Improvements", 92 | "values" : { 93 | "Normal" : { 94 | "started" : 72919, 95 | "finished" : 67481 96 | }, 97 | "With Header" : { 98 | "started" : 72880, 99 | "finished" : 66554 100 | }, 101 | "Expanded View" : { 102 | "started" : 73057, 103 | "finished" : 68062 104 | }, 105 | "Header + Expanded" : { 106 | "started" : 73376, 107 | "finished" : 68164 108 | } 109 | } 110 | } 111 | 112 | ``` -------------------------------------------------------------------------------- /abtest-dashboard.coffee: -------------------------------------------------------------------------------- 1 | Meteor.startup -> 2 | Template.abtests.created = -> 3 | Meteor.subscribe "ABTests" 4 | 5 | Template.abtests.helpers 6 | tests: -> ABTests.find() 7 | decFormat: (num) -> num.toFixed(2) 8 | intFormat: (num) -> num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") 9 | vsControlClass: -> 10 | if this.isControl 11 | "hidden" 12 | else if this.vsControl > 0 13 | "text-success" 14 | else if this.vsControl < 0 15 | "text-error" 16 | else 17 | "muted" 18 | significantClass: -> 19 | if this.isSignificant() 20 | "text-success" 21 | else 22 | "muted" 23 | 24 | alternatives: -> 25 | alts = [] 26 | control = null 27 | values = [] 28 | for alternative of this.values 29 | values.push 30 | name: alternative 31 | started: this.values[alternative].started 32 | finished: this.values[alternative].finished 33 | rank: this.values[alternative].rank 34 | for v in values.sort((a, b) -> a.rank - b.rank) 35 | started = v.started 36 | finished = v.finished 37 | finishedRate = if started then finished * 100 / started else 0 38 | item = 39 | isControl: not control 40 | isSignificant: -> 41 | return false if not this.control or (this.finished < 10 and this.control.finished < 10) 42 | n = this.finished + this.control.finished 43 | d = Math.abs this.finished - this.control.finished 44 | Math.pow(d, 2) > n 45 | name: v.name 46 | started: started 47 | finished: finished 48 | nonfinished: started - finished 49 | finishedRate: finishedRate 50 | vsControl: if control?.finishedRate then (finishedRate - control.finishedRate) * 100 / control.finishedRate else 0 51 | control: control 52 | alts.push item 53 | control = item if not control 54 | 55 | alts 56 | significant: -> 57 | return "" if this.isControl 58 | if this.isSignificant() then "YES" else "NO" 59 | 60 | totals: -> 61 | started = 0 62 | finished = 0 63 | for alternative of this.values 64 | started += this.values[alternative].started 65 | finished += this.values[alternative].finished 66 | return { 67 | started: started 68 | finished: finished 69 | nonfinished: started - finished 70 | finishedRate: if started then finished * 100 / started else 0 71 | } 72 | Template.abtests.events 73 | 'click #resetTest': -> 74 | if confirm("Are you sure you want to delete test: \n#{this.name}") 75 | ABTest.reset this.name --------------------------------------------------------------------------------