Browse Source

added first A/B experiment

Danny Coates 7 years ago
parent
commit
17e61bb09d
9 changed files with 159 additions and 10 deletions
  1. 76 0
      app/experiments.js
  2. 6 0
      app/main.js
  3. 24 3
      app/metrics.js
  4. 21 1
      app/storage.js
  5. 6 4
      app/templates/welcome.js
  6. 15 2
      assets/main.css
  7. 6 0
      package-lock.json
  8. 1 0
      package.json
  9. 4 0
      server/state.js

+ 76 - 0
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);
+    }
+  }
+}

+ 6 - 0
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');

+ 24 - 3
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)
   );
 }
 

+ 21 - 1
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);

+ 6 - 4
app/templates/welcome.js

@@ -13,7 +13,8 @@ module.exports = function(state, emit) {
         'uploadPageLearnMore'
       )}</a>
     </div>
-    <div class="upload-window" ondragover=${dragover} ondragleave=${dragleave}>
+    <div class="${state.config
+      .uploadWindowStyle}" ondragover=${dragover} ondragleave=${dragleave}>
       <div id="upload-img"><img src="${assets.get(
         'upload.svg'
       )}" title="${state.translate('uploadSvgAlt')}"/></div>
@@ -22,9 +23,10 @@ module.exports = function(state, emit) {
         'uploadPageSizeMessage'
       )}</em></span>
       <form method="post" action="upload" enctype="multipart/form-data">
-        <label for="file-upload" id="browse" class="btn">${state.translate(
-          'uploadPageBrowseButton1'
-        )}</label>
+        <label for="file-upload" id="browse" class="${state.config
+          .uploadButtonStyle}">${state.translate(
+    'uploadPageBrowseButton1'
+  )}</label>
         <input id="file-upload" type="file" name="fileUploaded" onchange=${upload} />
       </form>
     </div>

+ 15 - 2
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;
 }

+ 6 - 0
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",

+ 1 - 0
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",

+ 4 - 0
server/state.js

@@ -15,6 +15,10 @@ module.exports = function(req) {
     storage: {
       files: []
     },
+    config: {
+      uploadWindowStyle: 'upload-window',
+      uploadButtonStyle: 'browse btn'
+    },
     layout
   };
 };