added first A/B experiment

This commit is contained in:
Danny Coates 2017-09-11 17:09:29 -07:00
parent 14e21988b2
commit 17e61bb09d
No known key found for this signature in database
GPG Key ID: 4C442633C62E00CB
9 changed files with 159 additions and 10 deletions

76
app/experiments.js Normal file
View File

@ -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);
}
}
}

View File

@ -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');

View File

@ -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)
);
}

View File

@ -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);

View File

@ -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>

View File

@ -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
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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