From 17e61bb09d2e5ae0a5b127ba2293013beedc1886 Mon Sep 17 00:00:00 2001 From: Danny Coates Date: Mon, 11 Sep 2017 17:09:29 -0700 Subject: [PATCH] added first A/B experiment --- app/experiments.js | 76 ++++++++++++++++++++++++++++++++++++++++ app/main.js | 6 ++++ app/metrics.js | 27 ++++++++++++-- app/storage.js | 22 +++++++++++- app/templates/welcome.js | 10 +++--- assets/main.css | 17 +++++++-- package-lock.json | 6 ++++ package.json | 1 + server/state.js | 4 +++ 9 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 app/experiments.js diff --git a/app/experiments.js b/app/experiments.js new file mode 100644 index 00000000..aecee475 --- /dev/null +++ b/app/experiments.js @@ -0,0 +1,76 @@ +import hash from 'string-hash'; + +const experiments = { + '5YHCzn2CQTmBwWwTmZupBA': { + id: '5YHCzn2CQTmBwWwTmZupBA', + run: function(variant, state, emitter) { + state.experiment = { + xid: this.id, + xvar: variant + }; + // Beefy UI + if (variant === 1) { + state.config.uploadWindowStyle = 'upload-window upload-window-b'; + state.config.uploadButtonStyle = 'btn browse browse-b'; + } else { + state.config.uploadWindowStyle = 'upload-window'; + state.config.uploadButtonStyle = 'btn browse'; + } + emitter.emit('render'); + }, + eligible: function(state) { + return this.luckyNumber(state) >= 0.5; + }, + variant: function(state) { + return this.luckyNumber(state) < 0.5 ? 0 : 1; + }, + luckyNumber: function(state) { + return luckyNumber( + `${this.id}:${state.storage.get('testpilot_ga__cid')}` + ); + } + } +}; + +//Returns a number between 0 and 1 +function luckyNumber(str) { + return hash(str) / 0xffffffff; +} + +function checkExperiments(state, emitter) { + const all = Object.keys(experiments); + const id = all.find(id => experiments[id].eligible(state)); + if (id) { + const variant = experiments[id].variant(state); + state.storage.enroll(id, variant); + experiments[id].run(variant, state, emitter); + } +} + +export default function initialize(state, emitter) { + emitter.on('DOMContentLoaded', () => { + const xp = experiments[state.query.x]; + if (xp) { + xp.run(state.query.v, state, emitter); + } + }); + + if (!state.storage.get('testpilot_ga__cid')) { + // first ever visit. check again after cid is assigned. + emitter.on('DOMContentLoaded', () => { + checkExperiments(state, emitter); + }); + } else { + const enrolled = state.storage.enrolled; + enrolled.forEach(([id, variant]) => { + const xp = experiments[id]; + if (xp) { + xp.run(variant, state, emitter); + } + }); + // single experiment per session for now + if (enrolled.length === 0) { + checkExperiments(state, emitter); + } + } +} diff --git a/app/main.js b/app/main.js index 39494995..b222d129 100644 --- a/app/main.js +++ b/app/main.js @@ -7,6 +7,7 @@ import { canHasSend } from './utils'; import assets from '../common/assets'; import storage from './storage'; import metrics from './metrics'; +import experiments from './experiments'; import Raven from 'raven-js'; if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) { @@ -22,6 +23,10 @@ app.use((state, emitter) => { state.translate = locale.getTranslator(); state.storage = storage; state.raven = Raven; + state.config = { + uploadWindowStyle: 'upload-window', + uploadButtonStyle: 'browse btn' + }; emitter.on('DOMContentLoaded', async () => { const ok = await canHasSend(assets.get('cryptofill.js')); if (!ok) { @@ -34,5 +39,6 @@ app.use((state, emitter) => { app.use(metrics); app.use(fileManager); app.use(dragManager); +app.use(experiments); app.mount('#page-one'); diff --git a/app/metrics.js b/app/metrics.js index 61f99971..ecb2c54d 100644 --- a/app/metrics.js +++ b/app/metrics.js @@ -15,23 +15,44 @@ const analytics = new testPilotGA({ }); let appState = null; +let experiment = null; export default function initialize(state, emitter) { appState = state; emitter.on('DOMContentLoaded', () => { addExitHandlers(); + experiment = storage.enrolled[0]; + sendEvent(category(), 'visit', { + cm5: storage.totalUploads, + cm6: storage.files.length, + cm7: storage.totalDownloads + }); //TODO restart handlers... somewhere }); } function category() { - return appState.route === '/' ? 'sender' : 'recipient'; + switch (appState.route) { + case '/': + case '/share/:id': + return 'sender'; + case '/download/:id/:key': + case '/download/:id': + case '/completed': + return 'recipient'; + default: + return 'other'; + } } function sendEvent() { + const args = Array.from(arguments); + if (experiment && args[2]) { + args[2].xid = experiment[0]; + args[2].xvar = experiment[1]; + } return ( - hasLocalStorage && - analytics.sendEvent.apply(analytics, arguments).catch(() => 0) + hasLocalStorage && analytics.sendEvent.apply(analytics, args).catch(() => 0) ); } diff --git a/app/storage.js b/app/storage.js index 75483b42..209d6237 100644 --- a/app/storage.js +++ b/app/storage.js @@ -42,7 +42,11 @@ class Storage { const k = this.engine.key(i); if (isFile(k)) { try { - fs.push(JSON.parse(this.engine.getItem(k))); + const f = JSON.parse(this.engine.getItem(k)); + if (!f.id) { + f.id = f.fileId; + } + fs.push(f); } catch (err) { // obviously you're not a golfer this.engine.removeItem(k); @@ -70,6 +74,18 @@ class Storage { set referrer(str) { this.engine.setItem('referrer', str); } + get enrolled() { + return JSON.parse(this.engine.getItem('experiments') || '[]'); + } + + enroll(id, variant) { + const enrolled = this.enrolled; + // eslint-disable-next-line no-unused-vars + if (!enrolled.find(([i, v]) => i === id)) { + enrolled.push([id, variant]); + this.engine.setItem('experiments', JSON.stringify(enrolled)); + } + } get files() { return this._files; @@ -83,6 +99,10 @@ class Storage { } } + get(id) { + return this.engine.getItem(id); + } + remove(property) { if (isFile(property)) { this._files.splice(this._files.findIndex(f => f.id === property), 1); diff --git a/app/templates/welcome.js b/app/templates/welcome.js index 27077b3c..582d3186 100644 --- a/app/templates/welcome.js +++ b/app/templates/welcome.js @@ -13,7 +13,8 @@ module.exports = function(state, emit) { 'uploadPageLearnMore' )} -
+
@@ -22,9 +23,10 @@ module.exports = function(state, emit) { 'uploadPageSizeMessage' )}
- +
diff --git a/assets/main.css b/assets/main.css index b96a3008..c285f713 100644 --- a/assets/main.css +++ b/assets/main.css @@ -231,6 +231,14 @@ a { text-align: center; } +.upload-window-b { + border: 3px dashed rgba(0, 148, 251, 0.5); +} + +.upload-window-b.ondrag { + border: 5px dashed rgba(0, 148, 251, 0.5); +} + .link { color: #0094fb; text-decoration: none; @@ -247,7 +255,7 @@ a { font-family: 'SF Pro Text', sans-serif; } -#browse { +.browse { background: #0297f8; border-radius: 5px; font-size: 15px; @@ -261,10 +269,15 @@ a { padding: 0 10px; } -#browse:hover { +.browse:hover { background-color: #0287e8; } +.browse-b { + height: 60px; + font-size: 20px; +} + input[type="file"] { display: none; } diff --git a/package-lock.json b/package-lock.json index 6ae76cd7..df805485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10466,6 +10466,12 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" }, + "string-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", + "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=", + "dev": true + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", diff --git a/package.json b/package.json index 81c0ce9f..64d62d9c 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "rimraf": "^2.6.1", "selenium-webdriver": "^3.5.0", "sinon": "^3.2.1", + "string-hash": "^1.1.3", "stylelint-config-standard": "^17.0.0", "stylelint-no-unsupported-browser-features": "^1.0.0", "supertest": "^3.0.0", diff --git a/server/state.js b/server/state.js index 6dc889ed..5e4b03da 100644 --- a/server/state.js +++ b/server/state.js @@ -15,6 +15,10 @@ module.exports = function(req) { storage: { files: [] }, + config: { + uploadWindowStyle: 'upload-window', + uploadButtonStyle: 'browse btn' + }, layout }; };