From ab46630fd2b1d4f1a32c7687cd70fbd7951a0555 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Wed, 6 Mar 2013 15:10:59 +1100 Subject: [PATCH 01/31] Updated event tracking to set non-interaction hit The event tracking used to affect the bounce rate metric. Now it does not. --- lib/cohorts.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 5318adf..54c9045 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -5,22 +5,22 @@ Cohorts = (function() { var GoogleAnalyticsAdapter = { nameSpace: 'cohorts', - trackEvent: function(category, action, opt_label, opt_value) { + trackEvent: function(category, action, opt_label, opt_value, int_hit) { Utils.log('GA trackEvent: ' + category + ', ' + action + ', ' + opt_label + ', ' + opt_value); if(window['_gaq']) { - _gaq.push(['_trackEvent', category, action, opt_label, opt_value]); + _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); } else { throw(" _gaq object not found: It looks like you haven't correctly setup the asynchronous Google Analytics tracking code, and you are using the default GoogleAnalyticsAdapter."); } }, onInitialize: function(inTest, testName, cohort) { if(inTest) { - this.trackEvent(this.nameSpace, testName, cohort + ' | Total'); + this.trackEvent(this.nameSpace, testName, cohort + ' | Total',,true); } }, onEvent: function(testName, cohort, eventName) { - this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName); + this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName,,false); } }; @@ -480,4 +480,4 @@ Cohorts = (function() { Cookies: Cookies, Options: Options }; -})(); \ No newline at end of file +})(); From c22836e1faf771813e7b2d26d72d3e7907402174 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sat, 9 Mar 2013 13:26:37 +1100 Subject: [PATCH 02/31] Added custom variable tracking Under storage adapter, I have added the custom variable slot and setCustomVar function. Under the test object, I have created a new variable called scope. I intend to make this work with a cookie function. i.e. depending on the scope (1: visitor, 2: session, 3: page), the cookies will persist for that particular scope. --- lib/cohorts.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 54c9045..870cee0 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -5,22 +5,24 @@ Cohorts = (function() { var GoogleAnalyticsAdapter = { nameSpace: 'cohorts', - trackEvent: function(category, action, opt_label, opt_value, int_hit) { + cv_slot: 1, + trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { Utils.log('GA trackEvent: ' + category + ', ' + action + ', ' + opt_label + ', ' + opt_value); if(window['_gaq']) { - _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); + if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event + _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); } else { throw(" _gaq object not found: It looks like you haven't correctly setup the asynchronous Google Analytics tracking code, and you are using the default GoogleAnalyticsAdapter."); } }, - onInitialize: function(inTest, testName, cohort) { + onInitialize: function(inTest, testName, cohort, cv_slot, scope) { if(inTest) { - this.trackEvent(this.nameSpace, testName, cohort + ' | Total',,true); + this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); } }, onEvent: function(testName, cohort, eventName) { - this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName,,false); + this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false); } }; @@ -33,6 +35,7 @@ Cohorts = (function() { this.options = Utils.extend({ name: null, cohorts: null, + scope: 1, sample: 1.0, storageAdapter: null }, options); @@ -86,7 +89,7 @@ Cohorts = (function() { } else { var chosen_cohort = this.getCohort(); } - this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort); + this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.storageAdapter.cv_slot, this.options.scope); // call the onChosen handler, if it exists if(this.options.cohorts[chosen_cohort].onChosen) From c43f213d5571fe0d664b0228dda3874d624facb4 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sat, 9 Mar 2013 17:47:18 +1100 Subject: [PATCH 03/31] Updated Readme with changes to the test code Have included the following new features: * Custom variable tracking * Setting the test's scope (e.g. persist cookies for page, session or visitor lifetime) * Removed a bunch of code not in use * Made it mandatory to include a storage adapter with each experiment --- README.textile | 119 +++++++++++++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 49 deletions(-) diff --git a/README.textile b/README.textile index ead837b..eada11c 100644 --- a/README.textile +++ b/README.textile @@ -2,9 +2,9 @@ h1. Cohorts Cohorts is a simple, purely javascript, multivariate testing framework. -It allows you to easily run split tests for visitors on your site, showing them different designs, layouts, or whatever you want. Cohorts also allows you to track interesting events that occur for each of the cohorts. By default, it uses Google Analytics event tracking to store data, but you can customize it to use your own or another. +It allows you to easily run split tests for visitors on your site, showing them different designs, layouts, or whatever you want. Cohorts also allows you to track interesting events that occur for each of the cohorts. There are examples to use it with Google Analytics and SnowPlow Analytics, but you can easily customize it to store data in any analytics platform. -Note that Cohorts will not do any analysis for you: it's up to you to carefully consider analyze the data gathered to make business decisions. +Note that Cohorts will not do any analysis for you: it's up to you to carefully analyze the data gathered to make business decisions. Since the framework is purely javascript based, it's especially useful if you're working in an environment that has page and fragment caching. @@ -19,44 +19,65 @@ h2. Basic Usage For example, say you want to run a test to determine whether a bigger header link results in more clicks. In your page, you have both big and small header links, and they are not displayed by default. You can setup 2 cohorts like this:

-    // Note that this example assumes jQuery
-    var header_test = new Cohorts.Test({
-        name: 'big_vs_small_header',
-        sample: 1, // we want to include all visitors in the test
-        cohorts: {
-            big: {
-                onChosen: function() {
-                    $('#big').show();
-                }
-            },
-            small: {
-                onChosen: function() {
-                    $('#small').show();
-                }
+// Note that this example assumes jQuery
+$(document).ready(function(){
+
+    Cohorts.Options.debug = true;
+	font_test = new Cohorts.Test({
+		name: 'hello-goodbye',
+		scope: 1, // Set the scope for the test and custom variable: 1: Visitor, 2: Session, 3: Page 
+		sample: 1,
+		cohorts: {
+			hello: {
+				onChosen: function() {
+					$('p.message').html('Hello, world.');
+				}
+			},
+			goodbye: {
+				onChosen: function() {
+					$('p.message').html('Goodbye, world.');
+				}
+			},
+		},
+	
+    storageAdapter: {
+        nameSpace: 'cohorts',
+		cv_slot: 1, // Choose which custom variable slot you'd like to use
+        trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { 	
+			if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event
+			_gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]);
+        },
+        onInitialize: function(inTest, testName, cohort, cv_slot, scope) {
+            if(inTest && scope !== 3) {
+                this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope
             }
+        },
+        onEvent: function(testName, cohort, eventName) {
+            this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false);
         }
+    }
+	
+	});
+	
+	$('p.message').click(function() {
+        hello-goodbye.event('Clicked on message'); // Track any evens with your storage adapter
     });
-
-    $('#big').click(function() {
-        header_test.event('Clicked on Header');
-    });
-
-    $('#small').click(function() {
-        header_test.event('Clicked on Header');
-    });
-    
+	
+	// Add more test objects as you please
+	
+});
 
-After running this live on your site, you will see new data in the Event Tracking section of Google Analytics. +After running this on your site, you will see new data in the Event Tracking section of Google Analytics. -There will be a new category called "cohorts", and under that, all your tests will show up as "actions". In this case "big_vs_small_header" will be an action. +There will be a new category called "cohorts", and under that your tests will show up as "actions". In this case "big_vs_small_header" will be an action. Under each test action, you will see various labels. For this example, you will see the following labels: -* "big | Total" - The total number of people seeing the big header -* "big | Clicked on Header " - The total number of people in the big cohort that clicked on the header -* "small | Total" - The total number of people seeing the small header -* "small | Clicked on Header " - The total number of people in the small cohort that clicked on the header +* "hello" - The total number of people seeing "hello" +* "hello | Clicked on message" - The total number of people in the big cohort that converted +* "goodbye" - The total number of people seeing "goodbye" +* "goodbye | Clicked on message" - The total number of people in the small cohort that converted From this data, you can determine the effects the design change. @@ -67,6 +88,7 @@ h3. Constructor To initiate the test, create an instance of @Cohorts.Test@, which accepts an options hash with the following keys: * @name@ The name of the test. This needs to be unique. +* @scope@ Whether you want the test to persist at the level of a 1: Visitor, 2: Session, 3: Page (integer 1-3, 1 is recommended) * @sample@ A float from 0 to 1.0 representing the percentage of visitors that should be in the test. For example, if you specify 0.5, only 50% of visitors will be considered to be testable. This is useful if you want to restrict testing to a small percentage of visitors. * @cohorts@ Hash with keys of the names of the cohorts. For the values, you can specify a @onChosen@ function that should be executed as soon as the visitor resolves to that cohort. * @storageAdapter@ An object representing the data store you want to use. More details below. If you don't specify anything, it will defaults to using Google Analytics event tracking. @@ -84,23 +106,22 @@ Google Analytics event tracking is a great way to store data. They provide a gre If you want to use another data store, simply specify an object for the @storageAdapter@ parameter to the @Cohorts.Test@ constructor that looks like the following:

-myStorageAdapter = {
-    // Called when the Cohort.Test is initialized, and the visitor is resolved into a cohort.
-    //   inTest: whether the visitor is in the test
-    //   testName: the name of the test
-    //   cohort: the cohort chosen for the visitor. Will be null if the visitor isn't in the test
-    onInitialize: function(inTest, testName, cohort) {
-        // do initialization stuff
-    },
-    
-    // Called when the event method is for the Cohort.Test instance is called.
-    //   testName: the name of the test
-    //   cohort: the cohort of the visitor
-    //   eventName: the name of the event the visitor triggered
-    onEvent: function(testName, cohort, eventName) {
-        // do event stuff
+    storageAdapter: {
+        nameSpace: 'cohorts',
+    	cv_slot: 1, // Choose which custom variable slot you'd like to use
+        trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { 	
+			if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event
+			_gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]);
+        },
+        onInitialize: function(inTest, testName, cohort, cv_slot, scope) {
+            if(inTest && scope !== 3) {
+                this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope
+            }
+        },
+        onEvent: function(testName, cohort, eventName) {
+            this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false);
+        }
     }
-}
 
By specifying the above, you can easily hook up Cohorts to use your own data store. For example, you could make AJAX calls within @onInitialize@ and @onEvent@ to your web analytics service. @@ -110,7 +131,7 @@ h2. Forcing Cohorts It's useful when testing to force yourself into a particular cohort. You can specify a cohort via URL encoded params in a hash, like so:

-http://www.example.com/page.html#test1=cohort_foo&test2=cohort_bar
+http://www.example.com/page.html#hello-goodbye=hello&test2=cohort
 
This would force @test1@ to be in @cohort_foo@ and @test2@ to be in @cohort_bar@. @@ -125,4 +146,4 @@ You can have multiple tests running on a page, as long as your test names are un h2. TODO -* Ability to specify cookie expiry. \ No newline at end of file +* More examples From 8742bac914faddbaa24a23b048e5d75860befb9c Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sat, 9 Mar 2013 17:48:34 +1100 Subject: [PATCH 04/31] Beautified code to make it easier to read --- README.textile | 78 +++++++++++++++++++++++++------------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/README.textile b/README.textile index eada11c..f2ef154 100644 --- a/README.textile +++ b/README.textile @@ -20,51 +20,51 @@ For example, say you want to run a test to determine whether a bigger header lin

 // Note that this example assumes jQuery
-$(document).ready(function(){
+$(document).ready(function () {
 
     Cohorts.Options.debug = true;
-	font_test = new Cohorts.Test({
-		name: 'hello-goodbye',
-		scope: 1, // Set the scope for the test and custom variable: 1: Visitor, 2: Session, 3: Page 
-		sample: 1,
-		cohorts: {
-			hello: {
-				onChosen: function() {
-					$('p.message').html('Hello, world.');
-				}
-			},
-			goodbye: {
-				onChosen: function() {
-					$('p.message').html('Goodbye, world.');
-				}
-			},
-		},
-	
-    storageAdapter: {
-        nameSpace: 'cohorts',
-		cv_slot: 1, // Choose which custom variable slot you'd like to use
-        trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { 	
-			if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event
-			_gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]);
+    font_test = new Cohorts.Test({
+        name: 'hello-goodbye',
+        scope: 1, // Set the scope for the test and custom variable: 1: Visitor, 2: Session, 3: Page 
+        sample: 1,
+        cohorts: {
+            hello: {
+                onChosen: function () {
+                    $('p.message').html('Hello, world.');
+                }
+            },
+            goodbye: {
+                onChosen: function () {
+                    $('p.message').html('Goodbye, world.');
+                }
+            },
         },
-        onInitialize: function(inTest, testName, cohort, cv_slot, scope) {
-            if(inTest && scope !== 3) {
-                this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope
+
+        storageAdapter: {
+            nameSpace: 'cohorts',
+            cv_slot: 1, // Choose which custom variable slot you'd like to use
+            trackEvent: function (category, action, opt_label, opt_value, int_hit, cv_slot, scope) {
+                if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event
+                _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]);
+            },
+            onInitialize: function (inTest, testName, cohort, cv_slot, scope) {
+                if (inTest && scope !== 3) {
+                    this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope
+                }
+            },
+            onEvent: function (testName, cohort, eventName) {
+                this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false);
             }
-        },
-        onEvent: function(testName, cohort, eventName) {
-            this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false);
         }
-    }
-	
-	});
-	
-	$('p.message').click(function() {
-        hello-goodbye.event('Clicked on message'); // Track any evens with your storage adapter
+
     });
-	
-	// Add more test objects as you please
-	
+
+    $('p.message').click(function () {
+        hello - goodbye.event('Clicked on message'); // Track any evens with your storage adapter
+    });
+
+    // Add more test objects as you please
+
 });
 
From 5d0f2cc8aa769ddb407869faac1ecf268677c6b9 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sat, 9 Mar 2013 17:50:16 +1100 Subject: [PATCH 05/31] Updating test.html --- test/test.html | 128 +++++++++++++++++++++++++++---------------------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/test/test.html b/test/test.html index eb572e8..9343a95 100644 --- a/test/test.html +++ b/test/test.html @@ -1,59 +1,75 @@ + - - - test - - - + + - - -

- This is a big header -

- -

- This is a small header -

- - - - - \ No newline at end of file + Cohorts.Options.debug = true; + font_test = new Cohorts.Test({ + name: 'hello-goodbye', + scope: 1, // Set the scope for the test and custom variable: 1: Visitor, 2: Session, 3: Page + sample: 1, + cohorts: { + hello: { + onChosen: function() { + $('p.message').html('Hello, world.'); + } + }, + goodbye: { + onChosen: function() { + $('p.message').html('Goodbye, world.'); + } + }, + }, + + storageAdapter: { + nameSpace: 'cohorts', + cv_slot: 1, // Choose which custom variable slot you'd like to use + trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { + if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event + _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); + }, + onInitialize: function(inTest, testName, cohort, cv_slot, scope) { + if(inTest && scope !== 3) { + this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope + } + }, + onEvent: function(testName, cohort, eventName) { + this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false); + } + } + + }); + + $('p.message').click(function() { + hello-goodbye.event('Clicked on message'); // Track any evens with your storage adapter + }); + + // Add more test objects as you please + +}); + + + + +

Testing

+

+

For more information on how this works, check out the Readme.

+ + From 8e40bc69f075fe6de57a6f0698cda71e7de20f73 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sat, 9 Mar 2013 17:54:05 +1100 Subject: [PATCH 06/31] Updating cohorts.js Have included the following in this update: * Custom variable tracking * Setting the test's scope (e.g. persist cookies for page, session or visitor lifetime) * Removed a bunch of code not in use * Made it mandatory to include a storage adapter with each experiment --- lib/cohorts.js | 435 ++++++++++++++++--------------------------------- 1 file changed, 139 insertions(+), 296 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 870cee0..b69416d 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -1,86 +1,58 @@ -Cohorts = (function() { +Cohorts = (function () { var Options = { debug: false }; - - var GoogleAnalyticsAdapter = { - nameSpace: 'cohorts', - cv_slot: 1, - trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { - Utils.log('GA trackEvent: ' + category + ', ' + action + ', ' + opt_label + ', ' + opt_value); - - if(window['_gaq']) { - if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event - _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); - } else { - throw(" _gaq object not found: It looks like you haven't correctly setup the asynchronous Google Analytics tracking code, and you are using the default GoogleAnalyticsAdapter."); - } - }, - onInitialize: function(inTest, testName, cohort, cv_slot, scope) { - if(inTest) { - this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); - } - }, - onEvent: function(testName, cohort, eventName) { - this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false); - } - }; - + // The main test object - - var Test = (function() { + var Test = (function () { var cookiePrefix = '_cohorts'; - - var constructor = function(options) { + + var constructor = function (options) { this.options = Utils.extend({ name: null, cohorts: null, - scope: 1, + scope: 1, sample: 1.0, storageAdapter: null }, options); - + // Check params - if(this.options.name === null) - throw('A name for this test must be specified'); - if(this.options.cohorts === null) - throw('Cohorts must be specified for this test'); - if(Utils.size(options.cohorts) < 2) - throw('You must specify at least 2 cohorts for a test'); - if(!this.options.storageAdapter) - this.options.storageAdapter = GoogleAnalyticsAdapter; - + if (this.options.name === null) throw ('A name for this test must be specified'); + if (this.options.cohorts === null) throw ('Cohorts must be specified for this test'); + if (Utils.size(options.cohorts) < 2) throw ('You must specify at least 2 cohorts for a test'); + if (!this.options.storageAdapter) throw ('You must specify a storage adapter for a test'); + this.cohorts = Utils.keys(this.options.cohorts); this.run(); }; - + constructor.prototype = { - run: function() { + run: function () { // Determine whether there is forcing of cohorts via the URL var hash = window.location.hash; - if(hash.indexOf('#') == 0) hash = hash.slice(1,hash.length); + if (hash.indexOf('#') == 0) hash = hash.slice(1, hash.length); var pairs = hash.split('&'); - for(var i = 0; i < pairs.length; i++) { + for (var i = 0; i < pairs.length; i++) { var pair = pairs[i].split('='); var name = pair[0]; var cohort = pair[1]; - if(this.options.name == name) { + if (this.options.name == name) { Utils.log('Forcing test ' + name + ' into cohort ' + cohort); this.setCohort(cohort); } } - + // Determine whether user should be in the test var in_test = this.inTest(); - if(in_test === null) // haven't seen this user before - in_test = Math.random() <= this.options.sample; - - if(in_test) { + if (in_test === null) // haven't seen this user before + in_test = Math.random() <= this.options.sample; + + if (in_test) { this.setCookie('in_test', 1); - - if(!this.getCohort()) { + + if (!this.getCohort()) { // determine which cohort the user is chosen to be in var partitions = 1.0 / Utils.size(this.options.cohorts); var chosen_partition = Math.floor(Math.random() / partitions); @@ -90,81 +62,79 @@ Cohorts = (function() { var chosen_cohort = this.getCohort(); } this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.storageAdapter.cv_slot, this.options.scope); - + // call the onChosen handler, if it exists - if(this.options.cohorts[chosen_cohort].onChosen) - this.options.cohorts[chosen_cohort].onChosen(); + if (this.options.cohorts[chosen_cohort].onChosen) this.options.cohorts[chosen_cohort].onChosen(); } else { this.setCookie('in_test', 0); } }, - event: function(eventName) { - if(this.inTest()) - this.options.storageAdapter.onEvent(this.options.name, this.getCohort(), eventName); + event: function (eventName) { + if (this.inTest()) this.options.storageAdapter.onEvent(this.options.name, this.getCohort(), eventName); }, - inTest: function() { - if(this.getCookie('in_test') == 1) { + inTest: function () { + if (this.getCookie('in_test') == 1) { return true; - } else if(this.getCookie('in_test') == 0) { + } else if (this.getCookie('in_test') == 0) { return false; } else { return null; } }, - inCohort: function(cohort) { - if(this.inTest()) { + inCohort: function (cohort) { + if (this.inTest()) { return this.getCohort() == cohort; } else { return false; } }, - getCohort: function() { - if(this.inTest()) { + getCohort: function () { + if (this.inTest()) { return this.getCookie('chosen_cohort'); } else { return null; } }, - setCohort: function(cohort) { - if(this.cohorts.indexOf(cohort) == -1) { + setCohort: function (cohort) { + if (this.cohorts.indexOf(cohort) == -1) { return false; } else { this.setCookie('chosen_cohort', cohort); return true; } }, - setCookie: function(name, value) { - Cookies.set(cookiePrefix + '_' + this.options.name + '_' + name, value); + setCookie: function (name, value, options) { + Cookies.set(cookiePrefix + '_' + this.options.name + '_' + name, value, options, this.options.scope); }, - getCookie: function(name) { + getCookie: function (name) { return Cookies.get(cookiePrefix + '_' + this.options.name + '_' + name); } }; - + return constructor; })(); - + var Utils = { - extend: function(destination, source) { + extend: function (destination, source) { for (var property in source) - destination[property] = source[property]; + destination[property] = source[property]; return destination; }, - size: function(object) { + size: function (object) { var i = 0; for (var property in object) - i += 1; + i += 1; return i; }, - keys: function(object) { + keys: function (object) { var results = []; for (var property in object) - results.push(property); + results.push(property); return results; }, - log: function(message) { - if(window['console'] && Options.debug) { - if(console.log) { + log: function (message) { + if (window['console'] && Options.debug) { + if (console.log) { console.log(message); } else { alert(message); @@ -172,34 +142,31 @@ Cohorts = (function() { } } }; - + // Adapted from James Auldridge's jquery.cookies - var Cookies = ( function() - { + var Cookies = (function () { + var resolveOptions, assembleOptionsString, parseCookies, constructor, defaultOptions = { expiresAt: null, path: '/', - domain: null, + domain: null, secure: false }; + /** - * resolveOptions - receive an options object and ensure all options are present and valid, replacing with defaults where necessary - * - * @access private - * @static - * @parameter Object options - optional options to start with - * @return Object complete and valid options object - */ - resolveOptions = function( options ) - { + * resolveOptions - receive an options object and ensure all options are present and valid, replacing with defaults where necessary + * + * @access private + * @static + * @parameter Object options - optional options to start with + * @return Object complete and valid options object + */ + resolveOptions = function (options) { var returnValue, expireDate; - if( typeof options !== 'object' || options === null ) - { + if (typeof options !== 'object' || options === null) { returnValue = defaultOptions; - } - else - { + } else { returnValue = { expiresAt: defaultOptions.expiresAt, path: defaultOptions.path, @@ -207,87 +174,62 @@ Cohorts = (function() { secure: defaultOptions.secure }; - if( typeof options.expiresAt === 'object' && options.expiresAt instanceof Date ) - { + if (typeof options.expiresAt === 'object' && options.expiresAt instanceof Date) { returnValue.expiresAt = options.expiresAt; } - else if( typeof options.hoursToLive === 'number' && options.hoursToLive !== 0 ) - { - expireDate = new Date(); - expireDate.setTime( expireDate.getTime() + ( options.hoursToLive * 60 * 60 * 1000 ) ); - returnValue.expiresAt = expireDate; - } - if( typeof options.path === 'string' && options.path !== '' ) - { + if (typeof options.path === 'string' && options.path !== '') { returnValue.path = options.path; } - if( typeof options.domain === 'string' && options.domain !== '' ) - { + if (typeof options.domain === 'string' && options.domain !== '') { returnValue.domain = options.domain; } - if( options.secure === true ) - { + if (options.secure === true) { returnValue.secure = options.secure; } } return returnValue; - }; + }; /** - * assembleOptionsString - analyze options and assemble appropriate string for setting a cookie with those options - * - * @access private - * @static - * @parameter options OBJECT - optional options to start with - * @return STRING - complete and valid cookie setting options - */ - assembleOptionsString = function( options ) - { - options = resolveOptions( options ); - + * assembleOptionsString - analyze options and assemble appropriate string for setting a cookie with those options + * + * @access private + * @static + * @parameter options OBJECT - optional options to start with + * @return STRING - complete and valid cookie setting options + */ + assembleOptionsString = function (options) { return ( - ( typeof options.expiresAt === 'object' && options.expiresAt instanceof Date ? '; expires=' + options.expiresAt.toGMTString() : '' ) + - '; path=' + options.path + - ( typeof options.domain === 'string' ? '; domain=' + options.domain : '' ) + - ( options.secure === true ? '; secure' : '' ) - ); + '; expires=' + ((options.expiresAt === null) ? 0 : options.expiresAt.toGMTString()) + '; path=' + options.path + (typeof options.domain === 'string' ? '; domain=' + options.domain : '') + (options.secure === true ? '; secure' : '')); }; /** - * parseCookies - retrieve document.cookie string and break it into a hash with values decoded and unserialized - * - * @access private - * @static - * @return OBJECT - hash of cookies from document.cookie - */ - parseCookies = function() - { - var cookies = {}, i, pair, name, value, separated = document.cookie.split( ';' ), unparsedValue; - for( i = 0; i < separated.length; i = i + 1 ) - { - pair = separated[i].split( '=' ); - name = pair[0].replace( /^\s*/, '' ).replace( /\s*$/, '' ); - - try - { - value = decodeURIComponent( pair[1] ); - } - catch( e1 ) - { + * parseCookies - retrieve document.cookie string and break it into a hash with values decoded and unserialized + * + * @access private + * @static + * @return OBJECT - hash of cookies from document.cookie + */ + parseCookies = function () { + var cookies = {}, i, pair, name, value, separated = document.cookie.split(';'), + unparsedValue; + for (i = 0; i < separated.length; i = i + 1) { + pair = separated[i].split('='); + name = pair[0].replace(/^\s*/, '').replace(/\s*$/, ''); + + try { + value = decodeURIComponent(pair[1]); + } catch (e1) { value = pair[1]; } - if( typeof JSON === 'object' && JSON !== null && typeof JSON.parse === 'function' ) - { - try - { + if (typeof JSON === 'object' && JSON !== null && typeof JSON.parse === 'function') { + try { unparsedValue = value; - value = JSON.parse( value ); - } - catch( e2 ) - { + value = JSON.parse(value); + } catch (e2) { value = unparsedValue; } } @@ -297,7 +239,7 @@ Cohorts = (function() { return cookies; }; - constructor = function(){}; + constructor = function () {}; /** * get - get one, several, or all cookies @@ -306,62 +248,27 @@ Cohorts = (function() { * @paramater Mixed cookieName - String:name of single cookie; Array:list of multiple cookie names; Void (no param):if you want all cookies * @return Mixed - Value of cookie as set; Null:if only one cookie is requested and is not found; Object:hash of multiple or all cookies (if multiple or all requested); */ - constructor.prototype.get = function( cookieName ) - { + constructor.prototype.get = function (cookieName) { var returnValue, item, cookies = parseCookies(); - if( typeof cookieName === 'string' ) - { - returnValue = ( typeof cookies[cookieName] !== 'undefined' ) ? cookies[cookieName] : null; - } - else if( typeof cookieName === 'object' && cookieName !== null ) - { + if (typeof cookieName === 'string') { + returnValue = (typeof cookies[cookieName] !== 'undefined') ? cookies[cookieName] : null; + } else if (typeof cookieName === 'object' && cookieName !== null) { returnValue = {}; - for( item in cookieName ) - { - if( typeof cookies[cookieName[item]] !== 'undefined' ) - { + for (item in cookieName) { + if (typeof cookies[cookieName[item]] !== 'undefined') { returnValue[cookieName[item]] = cookies[cookieName[item]]; - } - else - { + } else { returnValue[cookieName[item]] = null; } } - } - else - { + } else { returnValue = cookies; } return returnValue; }; - /** - * filter - get array of cookies whose names match the provided RegExp - * - * @access public - * @paramater Object RegExp - The regular expression to match against cookie names - * @return Mixed - Object:hash of cookies whose names match the RegExp - */ - constructor.prototype.filter = function( cookieNameRegExp ) - { - var cookieName, returnValue = {}, cookies = parseCookies(); - if( typeof cookieNameRegExp === 'string' ) - { - cookieNameRegExp = new RegExp( cookieNameRegExp ); - } - - for( cookieName in cookies ) - { - if( cookieName.match( cookieNameRegExp ) ) - { - returnValue[cookieName] = cookies[cookieName]; - } - } - - return returnValue; - }; /** * set - set or delete a cookie with desired options * @@ -371,113 +278,49 @@ Cohorts = (function() { * @paramater Object options - optional list of cookie options to specify * @return void */ - constructor.prototype.set = function( cookieName, value, options ) - { - if( typeof options !== 'object' || options === null ) - { - options = {}; - } - - if( typeof value === 'undefined' || value === null ) - { - value = ''; - options.hoursToLive = -8760; - } + constructor.prototype.set = function (cookieName, value, options, scope) { + if (typeof options !== 'object' || options === null) { + var expire = new Date(); + + options = { + expiresAt: new Date(expire), + path: '/', + domain: null, + secure: false + }; - else if( typeof value !== 'string' ) - { - if( typeof JSON === 'object' && JSON !== null && typeof JSON.stringify === 'function' ) - { - value = JSON.stringify( value ); - } - else - { - throw new Error( 'cookies.set() received non-string value and could not serialize.' ); + // Expire cookies after 2yrs with visitor level scope + if (scope === 1) { + expire = expire.setTime(expire.getTime() + 3600000 * 24 * 730); + options.expiresAt = new Date(expire); } - } + // Set cookie to expire at session level for session scope + if (scope === 2) { + options.expiresAt = null; + } - var optionsString = assembleOptionsString( options ); - - document.cookie = cookieName + '=' + encodeURIComponent( value ) + optionsString; - }; - /** - * del - delete a cookie (domain and path options must match those with which the cookie was set; this is really an alias for set() with parameters simplified for this use) - * - * @access public - * @paramater MIxed cookieName - String name of cookie to delete, or Bool true to delete all - * @paramater Object options - optional list of cookie options to specify ( path, domain ) - * @return void - */ - constructor.prototype.del = function( cookieName, options ) - { - var allCookies = {}, name; - - if( typeof options !== 'object' || options === null ) - { - options = {}; } - if( typeof cookieName === 'boolean' && cookieName === true ) - { - allCookies = this.get(); - } - else if( typeof cookieName === 'string' ) - { - allCookies[cookieName] = true; - } - - for( name in allCookies ) - { - if( typeof name === 'string' && name !== '' ) - { - this.set( name, null, options ); + if (typeof value === 'undefined' || value === null) { + value = ''; + options.hoursToLive = -8760; + } else if (typeof value !== 'string') { + if (typeof JSON === 'object' && JSON !== null && typeof JSON.stringify === 'function') { + value = JSON.stringify(value); + } else { + throw new Error('cookies.set() received non-string value and could not serialize.'); } } - }; - /** - * test - test whether the browser is accepting cookies - * - * @access public - * @return Boolean - */ - constructor.prototype.test = function() - { - var returnValue = false, testName = 'cT', testValue = 'data'; - - this.set( testName, testValue ); - - if( this.get( testName ) === testValue ) - { - this.del( testName ); - returnValue = true; - } - return returnValue; - }; - /** - * setOptions - set default options for calls to cookie methods - * - * @access public - * @param Object options - list of cookie options to specify - * @return void - */ - constructor.prototype.setOptions = function( options ) - { - if( typeof options !== 'object' ) - { - options = null; - } + var optionsString = assembleOptionsString(options); - defaultOptions = resolveOptions( options ); + document.cookie = cookieName + '=' + encodeURIComponent(value) + optionsString; }; return new constructor(); - } )(); - - - // Return the public methods and objects - + })(); + return { Test: Test, Cookies: Cookies, From ccd935f39493ee94de4a165d7a7afd7ac853b99b Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Mon, 11 Mar 2013 12:32:53 +1100 Subject: [PATCH 07/31] Added GoogleAnalyticsAdapter back in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also defining cv_slot within the test object rather than the storage adapter object. --- lib/cohorts.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index b69416d..818876d 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -3,6 +3,22 @@ Cohorts = (function () { debug: false }; + var GoogleAnalyticsAdapter = { + nameSpace: 'cohorts', + trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { + if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event + _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); + }, + onInitialize: function(inTest, testName, cohort, cv_slot, scope) { + if(inTest && scope !== 3) { + this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope + } + }, + onEvent: function(testName, cohort, eventName) { + this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false); + } + } + // The main test object var Test = (function () { var cookiePrefix = '_cohorts'; @@ -12,6 +28,7 @@ Cohorts = (function () { name: null, cohorts: null, scope: 1, + cv_slot: null, sample: 1.0, storageAdapter: null }, options); @@ -20,7 +37,8 @@ Cohorts = (function () { if (this.options.name === null) throw ('A name for this test must be specified'); if (this.options.cohorts === null) throw ('Cohorts must be specified for this test'); if (Utils.size(options.cohorts) < 2) throw ('You must specify at least 2 cohorts for a test'); - if (!this.options.storageAdapter) throw ('You must specify a storage adapter for a test'); + if (!this.options.cv_slot) this.options.cv_slot = 5; + if (!this.options.storageAdapter) this.options.storageAdapter = GoogleAnalyticsAdapter; this.cohorts = Utils.keys(this.options.cohorts); @@ -61,7 +79,7 @@ Cohorts = (function () { } else { var chosen_cohort = this.getCohort(); } - this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.storageAdapter.cv_slot, this.options.scope); + this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.cv_slot, this.options.scope); // call the onChosen handler, if it exists if (this.options.cohorts[chosen_cohort].onChosen) this.options.cohorts[chosen_cohort].onChosen(); From c47bc82e196e3b89412224672b1659a6a0ac322e Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Mon, 11 Mar 2013 12:49:13 +1100 Subject: [PATCH 08/31] Updated Readme with James' suggestions --- README.textile | 113 ++++++++++++++++++++----------------------------- 1 file changed, 47 insertions(+), 66 deletions(-) diff --git a/README.textile b/README.textile index f2ef154..7d9b772 100644 --- a/README.textile +++ b/README.textile @@ -20,64 +20,42 @@ For example, say you want to run a test to determine whether a bigger header lin

 // Note that this example assumes jQuery
-$(document).ready(function () {
-
-    Cohorts.Options.debug = true;
-    font_test = new Cohorts.Test({
-        name: 'hello-goodbye',
-        scope: 1, // Set the scope for the test and custom variable: 1: Visitor, 2: Session, 3: Page 
-        sample: 1,
-        cohorts: {
-            hello: {
-                onChosen: function () {
-                    $('p.message').html('Hello, world.');
-                }
-            },
-            goodbye: {
-                onChosen: function () {
-                    $('p.message').html('Goodbye, world.');
-                }
-            },
-        },
-
-        storageAdapter: {
-            nameSpace: 'cohorts',
-            cv_slot: 1, // Choose which custom variable slot you'd like to use
-            trackEvent: function (category, action, opt_label, opt_value, int_hit, cv_slot, scope) {
-                if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event
-                _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]);
-            },
-            onInitialize: function (inTest, testName, cohort, cv_slot, scope) {
-                if (inTest && scope !== 3) {
-                    this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope
-                }
-            },
-            onEvent: function (testName, cohort, eventName) {
-                this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false);
-            }
-        }
-
-    });
-
-    $('p.message').click(function () {
-        hello - goodbye.event('Clicked on message'); // Track any evens with your storage adapter
-    });
-
-    // Add more test objects as you please
-
+$(document).ready(function() {
+	var a_test = new Cohorts.Test({
+		name: 'a_test',
+		scope: 1, // Sets the scope for the test and custom variable: 1: Visitor, 2: Session, 3: Page 
+		cv_slot: 5, // Sets the custom variable slot used in the GoogleAnalyticsAdapter
+		sample: 1,
+		cohorts: {
+			variation_a: {
+				onChosen: function() {
+					$('p.message').html('Hello.');
+				}
+			},
+			variation_b: {
+				onChosen: function() {
+					$('p.message').html('Sup?');
+				}
+			},
+		},
+	});
+	$('p.message').click(function() {
+		a_test.event('Clicked on message'); // Track any evens with your storage adapter
+	});
+	// Add more test objects as you need
 });
 
After running this on your site, you will see new data in the Event Tracking section of Google Analytics. -There will be a new category called "cohorts", and under that your tests will show up as "actions". In this case "big_vs_small_header" will be an action. +There will be a new category called "cohorts", and under that your tests will show up as "actions". In this case "a_test" will be an action. Under each test action, you will see various labels. For this example, you will see the following labels: -* "hello" - The total number of people seeing "hello" -* "hello | Clicked on message" - The total number of people in the big cohort that converted -* "goodbye" - The total number of people seeing "goodbye" -* "goodbye | Clicked on message" - The total number of people in the small cohort that converted +* "variation_a" - The total number of people seeing "variation_a" +* "variation_a | Clicked on message" - The total number of people in this cohort that converted +* "variation_b" - The total number of people seeing "variation_b" +* "variation_b | Clicked on message" - The total number of people in this cohort that converted From this data, you can determine the effects the design change. @@ -106,22 +84,25 @@ Google Analytics event tracking is a great way to store data. They provide a gre If you want to use another data store, simply specify an object for the @storageAdapter@ parameter to the @Cohorts.Test@ constructor that looks like the following:

-    storageAdapter: {
-        nameSpace: 'cohorts',
-    	cv_slot: 1, // Choose which custom variable slot you'd like to use
-        trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { 	
-			if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event
-			_gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]);
-        },
-        onInitialize: function(inTest, testName, cohort, cv_slot, scope) {
-            if(inTest && scope !== 3) {
-                this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope
-            }
-        },
-        onEvent: function(testName, cohort, eventName) {
-            this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false);
-        }
+myStorageAdapter = {
+    // Called when the Cohort.Test is initialized, and the visitor is resolved into a cohort.
+    //   inTest: whether the visitor is in the test
+    //   testName: the name of the test
+    //   cohort: the cohort chosen for the visitor. Will be null if the visitor isn't in the test
+    //   cv_slot: the custom variable slot the test will occupy for the web analytics tool used
+    //   scope: the scope of the test (whether test/tracking persists at the visitor level [1] or session level [2])
+    onInitialize: function(inTest, testName, cohort, cv_slot, scope) {
+        // do initialization stuff
+    },
+    
+    // Called when the event method is for the Cohort.Test instance is called.
+    //   testName: the name of the test
+    //   cohort: the cohort of the visitor
+    //   eventName: the name of the event the visitor triggered
+    onEvent: function(testName, cohort, eventName) {
+        // do event stuff
     }
+}
 
By specifying the above, you can easily hook up Cohorts to use your own data store. For example, you could make AJAX calls within @onInitialize@ and @onEvent@ to your web analytics service. @@ -134,7 +115,7 @@ It's useful when testing to force yourself into a particular cohort. You can spe http://www.example.com/page.html#hello-goodbye=hello&test2=cohort -This would force @test1@ to be in @cohort_foo@ and @test2@ to be in @cohort_bar@. +This would force @test1@ to be in @cohort_foo@ and @test2@ to be in @cohort_bar@. To preview your code in a production environment without exposing visitors to the test, you can just set the sample to 0 while you force particular versions of the page. h2. Details and Notes From c1657de9d6c6023d0a622de1927f92c31e5c514b Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Mon, 11 Mar 2013 12:50:34 +1100 Subject: [PATCH 09/31] Updated test.html with changes to script --- test/test.html | 46 ++++++++++++++-------------------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/test/test.html b/test/test.html index 9343a95..24c5098 100644 --- a/test/test.html +++ b/test/test.html @@ -6,51 +6,33 @@ - - - - -

- This is a big header -

- -

- This is a small header -

- - - - - \ No newline at end of file + + + + + + +

Testing

+

+

For more information on how this works, check out the Readme.

+ + From 83ca29671d3e9b172b3a2afbf62bc9aefbb4afdd Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Mon, 11 Mar 2013 13:01:14 +1100 Subject: [PATCH 11/31] Removed jQuery Library No real need for this in here. --- test/jquery-1.4.2.min.js | 154 --------------------------------------- 1 file changed, 154 deletions(-) delete mode 100644 test/jquery-1.4.2.min.js diff --git a/test/jquery-1.4.2.min.js b/test/jquery-1.4.2.min.js deleted file mode 100644 index 7c24308..0000000 --- a/test/jquery-1.4.2.min.js +++ /dev/null @@ -1,154 +0,0 @@ -/*! - * jQuery JavaScript Library v1.4.2 - * http://jquery.com/ - * - * Copyright 2010, John Resig - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * Includes Sizzle.js - * http://sizzlejs.com/ - * Copyright 2010, The Dojo Foundation - * Released under the MIT, BSD, and GPL Licenses. - * - * Date: Sat Feb 13 22:33:48 2010 -0500 - */ -(function(A,w){function ma(){if(!c.isReady){try{s.documentElement.doScroll("left")}catch(a){setTimeout(ma,1);return}c.ready()}}function Qa(a,b){b.src?c.ajax({url:b.src,async:false,dataType:"script"}):c.globalEval(b.text||b.textContent||b.innerHTML||"");b.parentNode&&b.parentNode.removeChild(b)}function X(a,b,d,f,e,j){var i=a.length;if(typeof b==="object"){for(var o in b)X(a,o,b[o],f,e,d);return a}if(d!==w){f=!j&&f&&c.isFunction(d);for(o=0;o)[^>]*$|^#([\w-]+)$/,Ua=/^.[^:#\[\.,]*$/,Va=/\S/, -Wa=/^(\s|\u00A0)+|(\s|\u00A0)+$/g,Xa=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,P=navigator.userAgent,xa=false,Q=[],L,$=Object.prototype.toString,aa=Object.prototype.hasOwnProperty,ba=Array.prototype.push,R=Array.prototype.slice,ya=Array.prototype.indexOf;c.fn=c.prototype={init:function(a,b){var d,f;if(!a)return this;if(a.nodeType){this.context=this[0]=a;this.length=1;return this}if(a==="body"&&!b){this.context=s;this[0]=s.body;this.selector="body";this.length=1;return this}if(typeof a==="string")if((d=Ta.exec(a))&& -(d[1]||!b))if(d[1]){f=b?b.ownerDocument||b:s;if(a=Xa.exec(a))if(c.isPlainObject(b)){a=[s.createElement(a[1])];c.fn.attr.call(a,b,true)}else a=[f.createElement(a[1])];else{a=sa([d[1]],[f]);a=(a.cacheable?a.fragment.cloneNode(true):a.fragment).childNodes}return c.merge(this,a)}else{if(b=s.getElementById(d[2])){if(b.id!==d[2])return T.find(a);this.length=1;this[0]=b}this.context=s;this.selector=a;return this}else if(!b&&/^\w+$/.test(a)){this.selector=a;this.context=s;a=s.getElementsByTagName(a);return c.merge(this, -a)}else return!b||b.jquery?(b||T).find(a):c(b).find(a);else if(c.isFunction(a))return T.ready(a);if(a.selector!==w){this.selector=a.selector;this.context=a.context}return c.makeArray(a,this)},selector:"",jquery:"1.4.2",length:0,size:function(){return this.length},toArray:function(){return R.call(this,0)},get:function(a){return a==null?this.toArray():a<0?this.slice(a)[0]:this[a]},pushStack:function(a,b,d){var f=c();c.isArray(a)?ba.apply(f,a):c.merge(f,a);f.prevObject=this;f.context=this.context;if(b=== -"find")f.selector=this.selector+(this.selector?" ":"")+d;else if(b)f.selector=this.selector+"."+b+"("+d+")";return f},each:function(a,b){return c.each(this,a,b)},ready:function(a){c.bindReady();if(c.isReady)a.call(s,c);else Q&&Q.push(a);return this},eq:function(a){return a===-1?this.slice(a):this.slice(a,+a+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(R.apply(this,arguments),"slice",R.call(arguments).join(","))},map:function(a){return this.pushStack(c.map(this, -function(b,d){return a.call(b,d,b)}))},end:function(){return this.prevObject||c(null)},push:ba,sort:[].sort,splice:[].splice};c.fn.init.prototype=c.fn;c.extend=c.fn.extend=function(){var a=arguments[0]||{},b=1,d=arguments.length,f=false,e,j,i,o;if(typeof a==="boolean"){f=a;a=arguments[1]||{};b=2}if(typeof a!=="object"&&!c.isFunction(a))a={};if(d===b){a=this;--b}for(;b
a"; -var e=d.getElementsByTagName("*"),j=d.getElementsByTagName("a")[0];if(!(!e||!e.length||!j)){c.support={leadingWhitespace:d.firstChild.nodeType===3,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/red/.test(j.getAttribute("style")),hrefNormalized:j.getAttribute("href")==="/a",opacity:/^0.55$/.test(j.style.opacity),cssFloat:!!j.style.cssFloat,checkOn:d.getElementsByTagName("input")[0].value==="on",optSelected:s.createElement("select").appendChild(s.createElement("option")).selected, -parentNode:d.removeChild(d.appendChild(s.createElement("div"))).parentNode===null,deleteExpando:true,checkClone:false,scriptEval:false,noCloneEvent:true,boxModel:null};b.type="text/javascript";try{b.appendChild(s.createTextNode("window."+f+"=1;"))}catch(i){}a.insertBefore(b,a.firstChild);if(A[f]){c.support.scriptEval=true;delete A[f]}try{delete b.test}catch(o){c.support.deleteExpando=false}a.removeChild(b);if(d.attachEvent&&d.fireEvent){d.attachEvent("onclick",function k(){c.support.noCloneEvent= -false;d.detachEvent("onclick",k)});d.cloneNode(true).fireEvent("onclick")}d=s.createElement("div");d.innerHTML="";a=s.createDocumentFragment();a.appendChild(d.firstChild);c.support.checkClone=a.cloneNode(true).cloneNode(true).lastChild.checked;c(function(){var k=s.createElement("div");k.style.width=k.style.paddingLeft="1px";s.body.appendChild(k);c.boxModel=c.support.boxModel=k.offsetWidth===2;s.body.removeChild(k).style.display="none"});a=function(k){var n= -s.createElement("div");k="on"+k;var r=k in n;if(!r){n.setAttribute(k,"return;");r=typeof n[k]==="function"}return r};c.support.submitBubbles=a("submit");c.support.changeBubbles=a("change");a=b=d=e=j=null}})();c.props={"for":"htmlFor","class":"className",readonly:"readOnly",maxlength:"maxLength",cellspacing:"cellSpacing",rowspan:"rowSpan",colspan:"colSpan",tabindex:"tabIndex",usemap:"useMap",frameborder:"frameBorder"};var G="jQuery"+J(),Ya=0,za={};c.extend({cache:{},expando:G,noData:{embed:true,object:true, -applet:true},data:function(a,b,d){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==A?za:a;var f=a[G],e=c.cache;if(!f&&typeof b==="string"&&d===w)return null;f||(f=++Ya);if(typeof b==="object"){a[G]=f;e[f]=c.extend(true,{},b)}else if(!e[f]){a[G]=f;e[f]={}}a=e[f];if(d!==w)a[b]=d;return typeof b==="string"?a[b]:a}},removeData:function(a,b){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==A?za:a;var d=a[G],f=c.cache,e=f[d];if(b){if(e){delete e[b];c.isEmptyObject(e)&&c.removeData(a)}}else{if(c.support.deleteExpando)delete a[c.expando]; -else a.removeAttribute&&a.removeAttribute(c.expando);delete f[d]}}}});c.fn.extend({data:function(a,b){if(typeof a==="undefined"&&this.length)return c.data(this[0]);else if(typeof a==="object")return this.each(function(){c.data(this,a)});var d=a.split(".");d[1]=d[1]?"."+d[1]:"";if(b===w){var f=this.triggerHandler("getData"+d[1]+"!",[d[0]]);if(f===w&&this.length)f=c.data(this[0],a);return f===w&&d[1]?this.data(d[0]):f}else return this.trigger("setData"+d[1]+"!",[d[0],b]).each(function(){c.data(this, -a,b)})},removeData:function(a){return this.each(function(){c.removeData(this,a)})}});c.extend({queue:function(a,b,d){if(a){b=(b||"fx")+"queue";var f=c.data(a,b);if(!d)return f||[];if(!f||c.isArray(d))f=c.data(a,b,c.makeArray(d));else f.push(d);return f}},dequeue:function(a,b){b=b||"fx";var d=c.queue(a,b),f=d.shift();if(f==="inprogress")f=d.shift();if(f){b==="fx"&&d.unshift("inprogress");f.call(a,function(){c.dequeue(a,b)})}}});c.fn.extend({queue:function(a,b){if(typeof a!=="string"){b=a;a="fx"}if(b=== -w)return c.queue(this[0],a);return this.each(function(){var d=c.queue(this,a,b);a==="fx"&&d[0]!=="inprogress"&&c.dequeue(this,a)})},dequeue:function(a){return this.each(function(){c.dequeue(this,a)})},delay:function(a,b){a=c.fx?c.fx.speeds[a]||a:a;b=b||"fx";return this.queue(b,function(){var d=this;setTimeout(function(){c.dequeue(d,b)},a)})},clearQueue:function(a){return this.queue(a||"fx",[])}});var Aa=/[\n\t]/g,ca=/\s+/,Za=/\r/g,$a=/href|src|style/,ab=/(button|input)/i,bb=/(button|input|object|select|textarea)/i, -cb=/^(a|area)$/i,Ba=/radio|checkbox/;c.fn.extend({attr:function(a,b){return X(this,a,b,true,c.attr)},removeAttr:function(a){return this.each(function(){c.attr(this,a,"");this.nodeType===1&&this.removeAttribute(a)})},addClass:function(a){if(c.isFunction(a))return this.each(function(n){var r=c(this);r.addClass(a.call(this,n,r.attr("class")))});if(a&&typeof a==="string")for(var b=(a||"").split(ca),d=0,f=this.length;d-1)return true;return false},val:function(a){if(a===w){var b=this[0];if(b){if(c.nodeName(b,"option"))return(b.attributes.value||{}).specified?b.value:b.text;if(c.nodeName(b,"select")){var d=b.selectedIndex,f=[],e=b.options;b=b.type==="select-one";if(d<0)return null;var j=b?d:0;for(d=b?d+1:e.length;j=0;else if(c.nodeName(this,"select")){var u=c.makeArray(r);c("option",this).each(function(){this.selected= -c.inArray(c(this).val(),u)>=0});if(!u.length)this.selectedIndex=-1}else this.value=r}})}});c.extend({attrFn:{val:true,css:true,html:true,text:true,data:true,width:true,height:true,offset:true},attr:function(a,b,d,f){if(!a||a.nodeType===3||a.nodeType===8)return w;if(f&&b in c.attrFn)return c(a)[b](d);f=a.nodeType!==1||!c.isXMLDoc(a);var e=d!==w;b=f&&c.props[b]||b;if(a.nodeType===1){var j=$a.test(b);if(b in a&&f&&!j){if(e){b==="type"&&ab.test(a.nodeName)&&a.parentNode&&c.error("type property can't be changed"); -a[b]=d}if(c.nodeName(a,"form")&&a.getAttributeNode(b))return a.getAttributeNode(b).nodeValue;if(b==="tabIndex")return(b=a.getAttributeNode("tabIndex"))&&b.specified?b.value:bb.test(a.nodeName)||cb.test(a.nodeName)&&a.href?0:w;return a[b]}if(!c.support.style&&f&&b==="style"){if(e)a.style.cssText=""+d;return a.style.cssText}e&&a.setAttribute(b,""+d);a=!c.support.hrefNormalized&&f&&j?a.getAttribute(b,2):a.getAttribute(b);return a===null?w:a}return c.style(a,b,d)}});var O=/\.(.*)$/,db=function(a){return a.replace(/[^\w\s\.\|`]/g, -function(b){return"\\"+b})};c.event={add:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){if(a.setInterval&&a!==A&&!a.frameElement)a=A;var e,j;if(d.handler){e=d;d=e.handler}if(!d.guid)d.guid=c.guid++;if(j=c.data(a)){var i=j.events=j.events||{},o=j.handle;if(!o)j.handle=o=function(){return typeof c!=="undefined"&&!c.event.triggered?c.event.handle.apply(o.elem,arguments):w};o.elem=a;b=b.split(" ");for(var k,n=0,r;k=b[n++];){j=e?c.extend({},e):{handler:d,data:f};if(k.indexOf(".")>-1){r=k.split("."); -k=r.shift();j.namespace=r.slice(0).sort().join(".")}else{r=[];j.namespace=""}j.type=k;j.guid=d.guid;var u=i[k],z=c.event.special[k]||{};if(!u){u=i[k]=[];if(!z.setup||z.setup.call(a,f,r,o)===false)if(a.addEventListener)a.addEventListener(k,o,false);else a.attachEvent&&a.attachEvent("on"+k,o)}if(z.add){z.add.call(a,j);if(!j.handler.guid)j.handler.guid=d.guid}u.push(j);c.event.global[k]=true}a=null}}},global:{},remove:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){var e,j=0,i,o,k,n,r,u,z=c.data(a), -C=z&&z.events;if(z&&C){if(b&&b.type){d=b.handler;b=b.type}if(!b||typeof b==="string"&&b.charAt(0)==="."){b=b||"";for(e in C)c.event.remove(a,e+b)}else{for(b=b.split(" ");e=b[j++];){n=e;i=e.indexOf(".")<0;o=[];if(!i){o=e.split(".");e=o.shift();k=new RegExp("(^|\\.)"+c.map(o.slice(0).sort(),db).join("\\.(?:.*\\.)?")+"(\\.|$)")}if(r=C[e])if(d){n=c.event.special[e]||{};for(B=f||0;B=0){a.type= -e=e.slice(0,-1);a.exclusive=true}if(!d){a.stopPropagation();c.event.global[e]&&c.each(c.cache,function(){this.events&&this.events[e]&&c.event.trigger(a,b,this.handle.elem)})}if(!d||d.nodeType===3||d.nodeType===8)return w;a.result=w;a.target=d;b=c.makeArray(b);b.unshift(a)}a.currentTarget=d;(f=c.data(d,"handle"))&&f.apply(d,b);f=d.parentNode||d.ownerDocument;try{if(!(d&&d.nodeName&&c.noData[d.nodeName.toLowerCase()]))if(d["on"+e]&&d["on"+e].apply(d,b)===false)a.result=false}catch(j){}if(!a.isPropagationStopped()&& -f)c.event.trigger(a,b,f,true);else if(!a.isDefaultPrevented()){f=a.target;var i,o=c.nodeName(f,"a")&&e==="click",k=c.event.special[e]||{};if((!k._default||k._default.call(d,a)===false)&&!o&&!(f&&f.nodeName&&c.noData[f.nodeName.toLowerCase()])){try{if(f[e]){if(i=f["on"+e])f["on"+e]=null;c.event.triggered=true;f[e]()}}catch(n){}if(i)f["on"+e]=i;c.event.triggered=false}}},handle:function(a){var b,d,f,e;a=arguments[0]=c.event.fix(a||A.event);a.currentTarget=this;b=a.type.indexOf(".")<0&&!a.exclusive; -if(!b){d=a.type.split(".");a.type=d.shift();f=new RegExp("(^|\\.)"+d.slice(0).sort().join("\\.(?:.*\\.)?")+"(\\.|$)")}e=c.data(this,"events");d=e[a.type];if(e&&d){d=d.slice(0);e=0;for(var j=d.length;e-1?c.map(a.options,function(f){return f.selected}).join("-"):"";else if(a.nodeName.toLowerCase()==="select")d=a.selectedIndex;return d},fa=function(a,b){var d=a.target,f,e;if(!(!da.test(d.nodeName)||d.readOnly)){f=c.data(d,"_change_data");e=Fa(d);if(a.type!=="focusout"||d.type!=="radio")c.data(d,"_change_data", -e);if(!(f===w||e===f))if(f!=null||e){a.type="change";return c.event.trigger(a,b,d)}}};c.event.special.change={filters:{focusout:fa,click:function(a){var b=a.target,d=b.type;if(d==="radio"||d==="checkbox"||b.nodeName.toLowerCase()==="select")return fa.call(this,a)},keydown:function(a){var b=a.target,d=b.type;if(a.keyCode===13&&b.nodeName.toLowerCase()!=="textarea"||a.keyCode===32&&(d==="checkbox"||d==="radio")||d==="select-multiple")return fa.call(this,a)},beforeactivate:function(a){a=a.target;c.data(a, -"_change_data",Fa(a))}},setup:function(){if(this.type==="file")return false;for(var a in ea)c.event.add(this,a+".specialChange",ea[a]);return da.test(this.nodeName)},teardown:function(){c.event.remove(this,".specialChange");return da.test(this.nodeName)}};ea=c.event.special.change.filters}s.addEventListener&&c.each({focus:"focusin",blur:"focusout"},function(a,b){function d(f){f=c.event.fix(f);f.type=b;return c.event.handle.call(this,f)}c.event.special[b]={setup:function(){this.addEventListener(a, -d,true)},teardown:function(){this.removeEventListener(a,d,true)}}});c.each(["bind","one"],function(a,b){c.fn[b]=function(d,f,e){if(typeof d==="object"){for(var j in d)this[b](j,f,d[j],e);return this}if(c.isFunction(f)){e=f;f=w}var i=b==="one"?c.proxy(e,function(k){c(this).unbind(k,i);return e.apply(this,arguments)}):e;if(d==="unload"&&b!=="one")this.one(d,f,e);else{j=0;for(var o=this.length;j0){y=t;break}}t=t[g]}m[q]=y}}}var f=/((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^[\]]*\]|['"][^'"]*['"]|[^[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g, -e=0,j=Object.prototype.toString,i=false,o=true;[0,0].sort(function(){o=false;return 0});var k=function(g,h,l,m){l=l||[];var q=h=h||s;if(h.nodeType!==1&&h.nodeType!==9)return[];if(!g||typeof g!=="string")return l;for(var p=[],v,t,y,S,H=true,M=x(h),I=g;(f.exec(""),v=f.exec(I))!==null;){I=v[3];p.push(v[1]);if(v[2]){S=v[3];break}}if(p.length>1&&r.exec(g))if(p.length===2&&n.relative[p[0]])t=ga(p[0]+p[1],h);else for(t=n.relative[p[0]]?[h]:k(p.shift(),h);p.length;){g=p.shift();if(n.relative[g])g+=p.shift(); -t=ga(g,t)}else{if(!m&&p.length>1&&h.nodeType===9&&!M&&n.match.ID.test(p[0])&&!n.match.ID.test(p[p.length-1])){v=k.find(p.shift(),h,M);h=v.expr?k.filter(v.expr,v.set)[0]:v.set[0]}if(h){v=m?{expr:p.pop(),set:z(m)}:k.find(p.pop(),p.length===1&&(p[0]==="~"||p[0]==="+")&&h.parentNode?h.parentNode:h,M);t=v.expr?k.filter(v.expr,v.set):v.set;if(p.length>0)y=z(t);else H=false;for(;p.length;){var D=p.pop();v=D;if(n.relative[D])v=p.pop();else D="";if(v==null)v=h;n.relative[D](y,v,M)}}else y=[]}y||(y=t);y||k.error(D|| -g);if(j.call(y)==="[object Array]")if(H)if(h&&h.nodeType===1)for(g=0;y[g]!=null;g++){if(y[g]&&(y[g]===true||y[g].nodeType===1&&E(h,y[g])))l.push(t[g])}else for(g=0;y[g]!=null;g++)y[g]&&y[g].nodeType===1&&l.push(t[g]);else l.push.apply(l,y);else z(y,l);if(S){k(S,q,l,m);k.uniqueSort(l)}return l};k.uniqueSort=function(g){if(B){i=o;g.sort(B);if(i)for(var h=1;h":function(g,h){var l=typeof h==="string";if(l&&!/\W/.test(h)){h=h.toLowerCase();for(var m=0,q=g.length;m=0))l||m.push(v);else if(l)h[p]=false;return false},ID:function(g){return g[1].replace(/\\/g,"")},TAG:function(g){return g[1].toLowerCase()}, -CHILD:function(g){if(g[1]==="nth"){var h=/(-?)(\d*)n((?:\+|-)?\d*)/.exec(g[2]==="even"&&"2n"||g[2]==="odd"&&"2n+1"||!/\D/.test(g[2])&&"0n+"+g[2]||g[2]);g[2]=h[1]+(h[2]||1)-0;g[3]=h[3]-0}g[0]=e++;return g},ATTR:function(g,h,l,m,q,p){h=g[1].replace(/\\/g,"");if(!p&&n.attrMap[h])g[1]=n.attrMap[h];if(g[2]==="~=")g[4]=" "+g[4]+" ";return g},PSEUDO:function(g,h,l,m,q){if(g[1]==="not")if((f.exec(g[3])||"").length>1||/^\w/.test(g[3]))g[3]=k(g[3],null,null,h);else{g=k.filter(g[3],h,l,true^q);l||m.push.apply(m, -g);return false}else if(n.match.POS.test(g[0])||n.match.CHILD.test(g[0]))return true;return g},POS:function(g){g.unshift(true);return g}},filters:{enabled:function(g){return g.disabled===false&&g.type!=="hidden"},disabled:function(g){return g.disabled===true},checked:function(g){return g.checked===true},selected:function(g){return g.selected===true},parent:function(g){return!!g.firstChild},empty:function(g){return!g.firstChild},has:function(g,h,l){return!!k(l[3],g).length},header:function(g){return/h\d/i.test(g.nodeName)}, -text:function(g){return"text"===g.type},radio:function(g){return"radio"===g.type},checkbox:function(g){return"checkbox"===g.type},file:function(g){return"file"===g.type},password:function(g){return"password"===g.type},submit:function(g){return"submit"===g.type},image:function(g){return"image"===g.type},reset:function(g){return"reset"===g.type},button:function(g){return"button"===g.type||g.nodeName.toLowerCase()==="button"},input:function(g){return/input|select|textarea|button/i.test(g.nodeName)}}, -setFilters:{first:function(g,h){return h===0},last:function(g,h,l,m){return h===m.length-1},even:function(g,h){return h%2===0},odd:function(g,h){return h%2===1},lt:function(g,h,l){return hl[3]-0},nth:function(g,h,l){return l[3]-0===h},eq:function(g,h,l){return l[3]-0===h}},filter:{PSEUDO:function(g,h,l,m){var q=h[1],p=n.filters[q];if(p)return p(g,l,h,m);else if(q==="contains")return(g.textContent||g.innerText||a([g])||"").indexOf(h[3])>=0;else if(q==="not"){h= -h[3];l=0;for(m=h.length;l=0}},ID:function(g,h){return g.nodeType===1&&g.getAttribute("id")===h},TAG:function(g,h){return h==="*"&&g.nodeType===1||g.nodeName.toLowerCase()===h},CLASS:function(g,h){return(" "+(g.className||g.getAttribute("class"))+" ").indexOf(h)>-1},ATTR:function(g,h){var l=h[1];g=n.attrHandle[l]?n.attrHandle[l](g):g[l]!=null?g[l]:g.getAttribute(l);l=g+"";var m=h[2];h=h[4];return g==null?m==="!=":m=== -"="?l===h:m==="*="?l.indexOf(h)>=0:m==="~="?(" "+l+" ").indexOf(h)>=0:!h?l&&g!==false:m==="!="?l!==h:m==="^="?l.indexOf(h)===0:m==="$="?l.substr(l.length-h.length)===h:m==="|="?l===h||l.substr(0,h.length+1)===h+"-":false},POS:function(g,h,l,m){var q=n.setFilters[h[2]];if(q)return q(g,l,h,m)}}},r=n.match.POS;for(var u in n.match){n.match[u]=new RegExp(n.match[u].source+/(?![^\[]*\])(?![^\(]*\))/.source);n.leftMatch[u]=new RegExp(/(^(?:.|\r|\n)*?)/.source+n.match[u].source.replace(/\\(\d+)/g,function(g, -h){return"\\"+(h-0+1)}))}var z=function(g,h){g=Array.prototype.slice.call(g,0);if(h){h.push.apply(h,g);return h}return g};try{Array.prototype.slice.call(s.documentElement.childNodes,0)}catch(C){z=function(g,h){h=h||[];if(j.call(g)==="[object Array]")Array.prototype.push.apply(h,g);else if(typeof g.length==="number")for(var l=0,m=g.length;l";var l=s.documentElement;l.insertBefore(g,l.firstChild);if(s.getElementById(h)){n.find.ID=function(m,q,p){if(typeof q.getElementById!=="undefined"&&!p)return(q=q.getElementById(m[1]))?q.id===m[1]||typeof q.getAttributeNode!=="undefined"&& -q.getAttributeNode("id").nodeValue===m[1]?[q]:w:[]};n.filter.ID=function(m,q){var p=typeof m.getAttributeNode!=="undefined"&&m.getAttributeNode("id");return m.nodeType===1&&p&&p.nodeValue===q}}l.removeChild(g);l=g=null})();(function(){var g=s.createElement("div");g.appendChild(s.createComment(""));if(g.getElementsByTagName("*").length>0)n.find.TAG=function(h,l){l=l.getElementsByTagName(h[1]);if(h[1]==="*"){h=[];for(var m=0;l[m];m++)l[m].nodeType===1&&h.push(l[m]);l=h}return l};g.innerHTML=""; -if(g.firstChild&&typeof g.firstChild.getAttribute!=="undefined"&&g.firstChild.getAttribute("href")!=="#")n.attrHandle.href=function(h){return h.getAttribute("href",2)};g=null})();s.querySelectorAll&&function(){var g=k,h=s.createElement("div");h.innerHTML="

";if(!(h.querySelectorAll&&h.querySelectorAll(".TEST").length===0)){k=function(m,q,p,v){q=q||s;if(!v&&q.nodeType===9&&!x(q))try{return z(q.querySelectorAll(m),p)}catch(t){}return g(m,q,p,v)};for(var l in g)k[l]=g[l];h=null}}(); -(function(){var g=s.createElement("div");g.innerHTML="
";if(!(!g.getElementsByClassName||g.getElementsByClassName("e").length===0)){g.lastChild.className="e";if(g.getElementsByClassName("e").length!==1){n.order.splice(1,0,"CLASS");n.find.CLASS=function(h,l,m){if(typeof l.getElementsByClassName!=="undefined"&&!m)return l.getElementsByClassName(h[1])};g=null}}})();var E=s.compareDocumentPosition?function(g,h){return!!(g.compareDocumentPosition(h)&16)}: -function(g,h){return g!==h&&(g.contains?g.contains(h):true)},x=function(g){return(g=(g?g.ownerDocument||g:0).documentElement)?g.nodeName!=="HTML":false},ga=function(g,h){var l=[],m="",q;for(h=h.nodeType?[h]:h;q=n.match.PSEUDO.exec(g);){m+=q[0];g=g.replace(n.match.PSEUDO,"")}g=n.relative[g]?g+"*":g;q=0;for(var p=h.length;q=0===d})};c.fn.extend({find:function(a){for(var b=this.pushStack("","find",a),d=0,f=0,e=this.length;f0)for(var j=d;j0},closest:function(a,b){if(c.isArray(a)){var d=[],f=this[0],e,j= -{},i;if(f&&a.length){e=0;for(var o=a.length;e-1:c(f).is(e)){d.push({selector:i,elem:f});delete j[i]}}f=f.parentNode}}return d}var k=c.expr.match.POS.test(a)?c(a,b||this.context):null;return this.map(function(n,r){for(;r&&r.ownerDocument&&r!==b;){if(k?k.index(r)>-1:c(r).is(a))return r;r=r.parentNode}return null})},index:function(a){if(!a||typeof a=== -"string")return c.inArray(this[0],a?c(a):this.parent().children());return c.inArray(a.jquery?a[0]:a,this)},add:function(a,b){a=typeof a==="string"?c(a,b||this.context):c.makeArray(a);b=c.merge(this.get(),a);return this.pushStack(qa(a[0])||qa(b[0])?b:c.unique(b))},andSelf:function(){return this.add(this.prevObject)}});c.each({parent:function(a){return(a=a.parentNode)&&a.nodeType!==11?a:null},parents:function(a){return c.dir(a,"parentNode")},parentsUntil:function(a,b,d){return c.dir(a,"parentNode", -d)},next:function(a){return c.nth(a,2,"nextSibling")},prev:function(a){return c.nth(a,2,"previousSibling")},nextAll:function(a){return c.dir(a,"nextSibling")},prevAll:function(a){return c.dir(a,"previousSibling")},nextUntil:function(a,b,d){return c.dir(a,"nextSibling",d)},prevUntil:function(a,b,d){return c.dir(a,"previousSibling",d)},siblings:function(a){return c.sibling(a.parentNode.firstChild,a)},children:function(a){return c.sibling(a.firstChild)},contents:function(a){return c.nodeName(a,"iframe")? -a.contentDocument||a.contentWindow.document:c.makeArray(a.childNodes)}},function(a,b){c.fn[a]=function(d,f){var e=c.map(this,b,d);eb.test(a)||(f=d);if(f&&typeof f==="string")e=c.filter(f,e);e=this.length>1?c.unique(e):e;if((this.length>1||gb.test(f))&&fb.test(a))e=e.reverse();return this.pushStack(e,a,R.call(arguments).join(","))}});c.extend({filter:function(a,b,d){if(d)a=":not("+a+")";return c.find.matches(a,b)},dir:function(a,b,d){var f=[];for(a=a[b];a&&a.nodeType!==9&&(d===w||a.nodeType!==1||!c(a).is(d));){a.nodeType=== -1&&f.push(a);a=a[b]}return f},nth:function(a,b,d){b=b||1;for(var f=0;a;a=a[d])if(a.nodeType===1&&++f===b)break;return a},sibling:function(a,b){for(var d=[];a;a=a.nextSibling)a.nodeType===1&&a!==b&&d.push(a);return d}});var Ja=/ jQuery\d+="(?:\d+|null)"/g,V=/^\s+/,Ka=/(<([\w:]+)[^>]*?)\/>/g,hb=/^(?:area|br|col|embed|hr|img|input|link|meta|param)$/i,La=/<([\w:]+)/,ib=/"},F={option:[1,""],legend:[1,"
","
"],thead:[1,"","
"],tr:[2,"","
"],td:[3,"","
"],col:[2,"","
"],area:[1,"",""],_default:[0,"",""]};F.optgroup=F.option;F.tbody=F.tfoot=F.colgroup=F.caption=F.thead;F.th=F.td;if(!c.support.htmlSerialize)F._default=[1,"div
","
"];c.fn.extend({text:function(a){if(c.isFunction(a))return this.each(function(b){var d= -c(this);d.text(a.call(this,b,d.text()))});if(typeof a!=="object"&&a!==w)return this.empty().append((this[0]&&this[0].ownerDocument||s).createTextNode(a));return c.text(this)},wrapAll:function(a){if(c.isFunction(a))return this.each(function(d){c(this).wrapAll(a.call(this,d))});if(this[0]){var b=c(a,this[0].ownerDocument).eq(0).clone(true);this[0].parentNode&&b.insertBefore(this[0]);b.map(function(){for(var d=this;d.firstChild&&d.firstChild.nodeType===1;)d=d.firstChild;return d}).append(this)}return this}, -wrapInner:function(a){if(c.isFunction(a))return this.each(function(b){c(this).wrapInner(a.call(this,b))});return this.each(function(){var b=c(this),d=b.contents();d.length?d.wrapAll(a):b.append(a)})},wrap:function(a){return this.each(function(){c(this).wrapAll(a)})},unwrap:function(){return this.parent().each(function(){c.nodeName(this,"body")||c(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.appendChild(a)})}, -prepend:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,this)});else if(arguments.length){var a=c(arguments[0]);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b, -this.nextSibling)});else if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,c(arguments[0]).toArray());return a}},remove:function(a,b){for(var d=0,f;(f=this[d])!=null;d++)if(!a||c.filter(a,[f]).length){if(!b&&f.nodeType===1){c.cleanData(f.getElementsByTagName("*"));c.cleanData([f])}f.parentNode&&f.parentNode.removeChild(f)}return this},empty:function(){for(var a=0,b;(b=this[a])!=null;a++)for(b.nodeType===1&&c.cleanData(b.getElementsByTagName("*"));b.firstChild;)b.removeChild(b.firstChild); -return this},clone:function(a){var b=this.map(function(){if(!c.support.noCloneEvent&&!c.isXMLDoc(this)){var d=this.outerHTML,f=this.ownerDocument;if(!d){d=f.createElement("div");d.appendChild(this.cloneNode(true));d=d.innerHTML}return c.clean([d.replace(Ja,"").replace(/=([^="'>\s]+\/)>/g,'="$1">').replace(V,"")],f)[0]}else return this.cloneNode(true)});if(a===true){ra(this,b);ra(this.find("*"),b.find("*"))}return b},html:function(a){if(a===w)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(Ja, -""):null;else if(typeof a==="string"&&!ta.test(a)&&(c.support.leadingWhitespace||!V.test(a))&&!F[(La.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Ka,Ma);try{for(var b=0,d=this.length;b0||e.cacheable||this.length>1?k.cloneNode(true):k)}o.length&&c.each(o,Qa)}return this}});c.fragments={};c.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){c.fn[a]=function(d){var f=[];d=c(d);var e=this.length===1&&this[0].parentNode;if(e&&e.nodeType===11&&e.childNodes.length===1&&d.length===1){d[b](this[0]); -return this}else{e=0;for(var j=d.length;e0?this.clone(true):this).get();c.fn[b].apply(c(d[e]),i);f=f.concat(i)}return this.pushStack(f,a,d.selector)}}});c.extend({clean:function(a,b,d,f){b=b||s;if(typeof b.createElement==="undefined")b=b.ownerDocument||b[0]&&b[0].ownerDocument||s;for(var e=[],j=0,i;(i=a[j])!=null;j++){if(typeof i==="number")i+="";if(i){if(typeof i==="string"&&!jb.test(i))i=b.createTextNode(i);else if(typeof i==="string"){i=i.replace(Ka,Ma);var o=(La.exec(i)||["", -""])[1].toLowerCase(),k=F[o]||F._default,n=k[0],r=b.createElement("div");for(r.innerHTML=k[1]+i+k[2];n--;)r=r.lastChild;if(!c.support.tbody){n=ib.test(i);o=o==="table"&&!n?r.firstChild&&r.firstChild.childNodes:k[1]===""&&!n?r.childNodes:[];for(k=o.length-1;k>=0;--k)c.nodeName(o[k],"tbody")&&!o[k].childNodes.length&&o[k].parentNode.removeChild(o[k])}!c.support.leadingWhitespace&&V.test(i)&&r.insertBefore(b.createTextNode(V.exec(i)[0]),r.firstChild);i=r.childNodes}if(i.nodeType)e.push(i);else e= -c.merge(e,i)}}if(d)for(j=0;e[j];j++)if(f&&c.nodeName(e[j],"script")&&(!e[j].type||e[j].type.toLowerCase()==="text/javascript"))f.push(e[j].parentNode?e[j].parentNode.removeChild(e[j]):e[j]);else{e[j].nodeType===1&&e.splice.apply(e,[j+1,0].concat(c.makeArray(e[j].getElementsByTagName("script"))));d.appendChild(e[j])}return e},cleanData:function(a){for(var b,d,f=c.cache,e=c.event.special,j=c.support.deleteExpando,i=0,o;(o=a[i])!=null;i++)if(d=o[c.expando]){b=f[d];if(b.events)for(var k in b.events)e[k]? -c.event.remove(o,k):Ca(o,k,b.handle);if(j)delete o[c.expando];else o.removeAttribute&&o.removeAttribute(c.expando);delete f[d]}}});var kb=/z-?index|font-?weight|opacity|zoom|line-?height/i,Na=/alpha\([^)]*\)/,Oa=/opacity=([^)]*)/,ha=/float/i,ia=/-([a-z])/ig,lb=/([A-Z])/g,mb=/^-?\d+(?:px)?$/i,nb=/^-?\d/,ob={position:"absolute",visibility:"hidden",display:"block"},pb=["Left","Right"],qb=["Top","Bottom"],rb=s.defaultView&&s.defaultView.getComputedStyle,Pa=c.support.cssFloat?"cssFloat":"styleFloat",ja= -function(a,b){return b.toUpperCase()};c.fn.css=function(a,b){return X(this,a,b,true,function(d,f,e){if(e===w)return c.curCSS(d,f);if(typeof e==="number"&&!kb.test(f))e+="px";c.style(d,f,e)})};c.extend({style:function(a,b,d){if(!a||a.nodeType===3||a.nodeType===8)return w;if((b==="width"||b==="height")&&parseFloat(d)<0)d=w;var f=a.style||a,e=d!==w;if(!c.support.opacity&&b==="opacity"){if(e){f.zoom=1;b=parseInt(d,10)+""==="NaN"?"":"alpha(opacity="+d*100+")";a=f.filter||c.curCSS(a,"filter")||"";f.filter= -Na.test(a)?a.replace(Na,b):b}return f.filter&&f.filter.indexOf("opacity=")>=0?parseFloat(Oa.exec(f.filter)[1])/100+"":""}if(ha.test(b))b=Pa;b=b.replace(ia,ja);if(e)f[b]=d;return f[b]},css:function(a,b,d,f){if(b==="width"||b==="height"){var e,j=b==="width"?pb:qb;function i(){e=b==="width"?a.offsetWidth:a.offsetHeight;f!=="border"&&c.each(j,function(){f||(e-=parseFloat(c.curCSS(a,"padding"+this,true))||0);if(f==="margin")e+=parseFloat(c.curCSS(a,"margin"+this,true))||0;else e-=parseFloat(c.curCSS(a, -"border"+this+"Width",true))||0})}a.offsetWidth!==0?i():c.swap(a,ob,i);return Math.max(0,Math.round(e))}return c.curCSS(a,b,d)},curCSS:function(a,b,d){var f,e=a.style;if(!c.support.opacity&&b==="opacity"&&a.currentStyle){f=Oa.test(a.currentStyle.filter||"")?parseFloat(RegExp.$1)/100+"":"";return f===""?"1":f}if(ha.test(b))b=Pa;if(!d&&e&&e[b])f=e[b];else if(rb){if(ha.test(b))b="float";b=b.replace(lb,"-$1").toLowerCase();e=a.ownerDocument.defaultView;if(!e)return null;if(a=e.getComputedStyle(a,null))f= -a.getPropertyValue(b);if(b==="opacity"&&f==="")f="1"}else if(a.currentStyle){d=b.replace(ia,ja);f=a.currentStyle[b]||a.currentStyle[d];if(!mb.test(f)&&nb.test(f)){b=e.left;var j=a.runtimeStyle.left;a.runtimeStyle.left=a.currentStyle.left;e.left=d==="fontSize"?"1em":f||0;f=e.pixelLeft+"px";e.left=b;a.runtimeStyle.left=j}}return f},swap:function(a,b,d){var f={};for(var e in b){f[e]=a.style[e];a.style[e]=b[e]}d.call(a);for(e in b)a.style[e]=f[e]}});if(c.expr&&c.expr.filters){c.expr.filters.hidden=function(a){var b= -a.offsetWidth,d=a.offsetHeight,f=a.nodeName.toLowerCase()==="tr";return b===0&&d===0&&!f?true:b>0&&d>0&&!f?false:c.curCSS(a,"display")==="none"};c.expr.filters.visible=function(a){return!c.expr.filters.hidden(a)}}var sb=J(),tb=//gi,ub=/select|textarea/i,vb=/color|date|datetime|email|hidden|month|number|password|range|search|tel|text|time|url|week/i,N=/=\?(&|$)/,ka=/\?/,wb=/(\?|&)_=.*?(&|$)/,xb=/^(\w+:)?\/\/([^\/?#]+)/,yb=/%20/g,zb=c.fn.load;c.fn.extend({load:function(a,b,d){if(typeof a!== -"string")return zb.call(this,a);else if(!this.length)return this;var f=a.indexOf(" ");if(f>=0){var e=a.slice(f,a.length);a=a.slice(0,f)}f="GET";if(b)if(c.isFunction(b)){d=b;b=null}else if(typeof b==="object"){b=c.param(b,c.ajaxSettings.traditional);f="POST"}var j=this;c.ajax({url:a,type:f,dataType:"html",data:b,complete:function(i,o){if(o==="success"||o==="notmodified")j.html(e?c("
").append(i.responseText.replace(tb,"")).find(e):i.responseText);d&&j.each(d,[i.responseText,o,i])}});return this}, -serialize:function(){return c.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?c.makeArray(this.elements):this}).filter(function(){return this.name&&!this.disabled&&(this.checked||ub.test(this.nodeName)||vb.test(this.type))}).map(function(a,b){a=c(this).val();return a==null?null:c.isArray(a)?c.map(a,function(d){return{name:b.name,value:d}}):{name:b.name,value:a}}).get()}});c.each("ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split(" "), -function(a,b){c.fn[b]=function(d){return this.bind(b,d)}});c.extend({get:function(a,b,d,f){if(c.isFunction(b)){f=f||d;d=b;b=null}return c.ajax({type:"GET",url:a,data:b,success:d,dataType:f})},getScript:function(a,b){return c.get(a,null,b,"script")},getJSON:function(a,b,d){return c.get(a,b,d,"json")},post:function(a,b,d,f){if(c.isFunction(b)){f=f||d;d=b;b={}}return c.ajax({type:"POST",url:a,data:b,success:d,dataType:f})},ajaxSetup:function(a){c.extend(c.ajaxSettings,a)},ajaxSettings:{url:location.href, -global:true,type:"GET",contentType:"application/x-www-form-urlencoded",processData:true,async:true,xhr:A.XMLHttpRequest&&(A.location.protocol!=="file:"||!A.ActiveXObject)?function(){return new A.XMLHttpRequest}:function(){try{return new A.ActiveXObject("Microsoft.XMLHTTP")}catch(a){}},accepts:{xml:"application/xml, text/xml",html:"text/html",script:"text/javascript, application/javascript",json:"application/json, text/javascript",text:"text/plain",_default:"*/*"}},lastModified:{},etag:{},ajax:function(a){function b(){e.success&& -e.success.call(k,o,i,x);e.global&&f("ajaxSuccess",[x,e])}function d(){e.complete&&e.complete.call(k,x,i);e.global&&f("ajaxComplete",[x,e]);e.global&&!--c.active&&c.event.trigger("ajaxStop")}function f(q,p){(e.context?c(e.context):c.event).trigger(q,p)}var e=c.extend(true,{},c.ajaxSettings,a),j,i,o,k=a&&a.context||e,n=e.type.toUpperCase();if(e.data&&e.processData&&typeof e.data!=="string")e.data=c.param(e.data,e.traditional);if(e.dataType==="jsonp"){if(n==="GET")N.test(e.url)||(e.url+=(ka.test(e.url)? -"&":"?")+(e.jsonp||"callback")+"=?");else if(!e.data||!N.test(e.data))e.data=(e.data?e.data+"&":"")+(e.jsonp||"callback")+"=?";e.dataType="json"}if(e.dataType==="json"&&(e.data&&N.test(e.data)||N.test(e.url))){j=e.jsonpCallback||"jsonp"+sb++;if(e.data)e.data=(e.data+"").replace(N,"="+j+"$1");e.url=e.url.replace(N,"="+j+"$1");e.dataType="script";A[j]=A[j]||function(q){o=q;b();d();A[j]=w;try{delete A[j]}catch(p){}z&&z.removeChild(C)}}if(e.dataType==="script"&&e.cache===null)e.cache=false;if(e.cache=== -false&&n==="GET"){var r=J(),u=e.url.replace(wb,"$1_="+r+"$2");e.url=u+(u===e.url?(ka.test(e.url)?"&":"?")+"_="+r:"")}if(e.data&&n==="GET")e.url+=(ka.test(e.url)?"&":"?")+e.data;e.global&&!c.active++&&c.event.trigger("ajaxStart");r=(r=xb.exec(e.url))&&(r[1]&&r[1]!==location.protocol||r[2]!==location.host);if(e.dataType==="script"&&n==="GET"&&r){var z=s.getElementsByTagName("head")[0]||s.documentElement,C=s.createElement("script");C.src=e.url;if(e.scriptCharset)C.charset=e.scriptCharset;if(!j){var B= -false;C.onload=C.onreadystatechange=function(){if(!B&&(!this.readyState||this.readyState==="loaded"||this.readyState==="complete")){B=true;b();d();C.onload=C.onreadystatechange=null;z&&C.parentNode&&z.removeChild(C)}}}z.insertBefore(C,z.firstChild);return w}var E=false,x=e.xhr();if(x){e.username?x.open(n,e.url,e.async,e.username,e.password):x.open(n,e.url,e.async);try{if(e.data||a&&a.contentType)x.setRequestHeader("Content-Type",e.contentType);if(e.ifModified){c.lastModified[e.url]&&x.setRequestHeader("If-Modified-Since", -c.lastModified[e.url]);c.etag[e.url]&&x.setRequestHeader("If-None-Match",c.etag[e.url])}r||x.setRequestHeader("X-Requested-With","XMLHttpRequest");x.setRequestHeader("Accept",e.dataType&&e.accepts[e.dataType]?e.accepts[e.dataType]+", */*":e.accepts._default)}catch(ga){}if(e.beforeSend&&e.beforeSend.call(k,x,e)===false){e.global&&!--c.active&&c.event.trigger("ajaxStop");x.abort();return false}e.global&&f("ajaxSend",[x,e]);var g=x.onreadystatechange=function(q){if(!x||x.readyState===0||q==="abort"){E|| -d();E=true;if(x)x.onreadystatechange=c.noop}else if(!E&&x&&(x.readyState===4||q==="timeout")){E=true;x.onreadystatechange=c.noop;i=q==="timeout"?"timeout":!c.httpSuccess(x)?"error":e.ifModified&&c.httpNotModified(x,e.url)?"notmodified":"success";var p;if(i==="success")try{o=c.httpData(x,e.dataType,e)}catch(v){i="parsererror";p=v}if(i==="success"||i==="notmodified")j||b();else c.handleError(e,x,i,p);d();q==="timeout"&&x.abort();if(e.async)x=null}};try{var h=x.abort;x.abort=function(){x&&h.call(x); -g("abort")}}catch(l){}e.async&&e.timeout>0&&setTimeout(function(){x&&!E&&g("timeout")},e.timeout);try{x.send(n==="POST"||n==="PUT"||n==="DELETE"?e.data:null)}catch(m){c.handleError(e,x,null,m);d()}e.async||g();return x}},handleError:function(a,b,d,f){if(a.error)a.error.call(a.context||a,b,d,f);if(a.global)(a.context?c(a.context):c.event).trigger("ajaxError",[b,a,f])},active:0,httpSuccess:function(a){try{return!a.status&&location.protocol==="file:"||a.status>=200&&a.status<300||a.status===304||a.status=== -1223||a.status===0}catch(b){}return false},httpNotModified:function(a,b){var d=a.getResponseHeader("Last-Modified"),f=a.getResponseHeader("Etag");if(d)c.lastModified[b]=d;if(f)c.etag[b]=f;return a.status===304||a.status===0},httpData:function(a,b,d){var f=a.getResponseHeader("content-type")||"",e=b==="xml"||!b&&f.indexOf("xml")>=0;a=e?a.responseXML:a.responseText;e&&a.documentElement.nodeName==="parsererror"&&c.error("parsererror");if(d&&d.dataFilter)a=d.dataFilter(a,b);if(typeof a==="string")if(b=== -"json"||!b&&f.indexOf("json")>=0)a=c.parseJSON(a);else if(b==="script"||!b&&f.indexOf("javascript")>=0)c.globalEval(a);return a},param:function(a,b){function d(i,o){if(c.isArray(o))c.each(o,function(k,n){b||/\[\]$/.test(i)?f(i,n):d(i+"["+(typeof n==="object"||c.isArray(n)?k:"")+"]",n)});else!b&&o!=null&&typeof o==="object"?c.each(o,function(k,n){d(i+"["+k+"]",n)}):f(i,o)}function f(i,o){o=c.isFunction(o)?o():o;e[e.length]=encodeURIComponent(i)+"="+encodeURIComponent(o)}var e=[];if(b===w)b=c.ajaxSettings.traditional; -if(c.isArray(a)||a.jquery)c.each(a,function(){f(this.name,this.value)});else for(var j in a)d(j,a[j]);return e.join("&").replace(yb,"+")}});var la={},Ab=/toggle|show|hide/,Bb=/^([+-]=)?([\d+-.]+)(.*)$/,W,va=[["height","marginTop","marginBottom","paddingTop","paddingBottom"],["width","marginLeft","marginRight","paddingLeft","paddingRight"],["opacity"]];c.fn.extend({show:function(a,b){if(a||a===0)return this.animate(K("show",3),a,b);else{a=0;for(b=this.length;a").appendTo("body");f=e.css("display");if(f==="none")f="block";e.remove();la[d]=f}c.data(this[a],"olddisplay",f)}}a=0;for(b=this.length;a=0;f--)if(d[f].elem===this){b&&d[f](true);d.splice(f,1)}});b||this.dequeue();return this}});c.each({slideDown:K("show",1),slideUp:K("hide",1),slideToggle:K("toggle",1),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"}},function(a,b){c.fn[a]=function(d,f){return this.animate(b,d,f)}});c.extend({speed:function(a,b,d){var f=a&&typeof a==="object"?a:{complete:d||!d&&b||c.isFunction(a)&&a,duration:a,easing:d&&b||b&&!c.isFunction(b)&&b};f.duration=c.fx.off?0:typeof f.duration=== -"number"?f.duration:c.fx.speeds[f.duration]||c.fx.speeds._default;f.old=f.complete;f.complete=function(){f.queue!==false&&c(this).dequeue();c.isFunction(f.old)&&f.old.call(this)};return f},easing:{linear:function(a,b,d,f){return d+f*a},swing:function(a,b,d,f){return(-Math.cos(a*Math.PI)/2+0.5)*f+d}},timers:[],fx:function(a,b,d){this.options=b;this.elem=a;this.prop=d;if(!b.orig)b.orig={}}});c.fx.prototype={update:function(){this.options.step&&this.options.step.call(this.elem,this.now,this);(c.fx.step[this.prop]|| -c.fx.step._default)(this);if((this.prop==="height"||this.prop==="width")&&this.elem.style)this.elem.style.display="block"},cur:function(a){if(this.elem[this.prop]!=null&&(!this.elem.style||this.elem.style[this.prop]==null))return this.elem[this.prop];return(a=parseFloat(c.css(this.elem,this.prop,a)))&&a>-10000?a:parseFloat(c.curCSS(this.elem,this.prop))||0},custom:function(a,b,d){function f(j){return e.step(j)}this.startTime=J();this.start=a;this.end=b;this.unit=d||this.unit||"px";this.now=this.start; -this.pos=this.state=0;var e=this;f.elem=this.elem;if(f()&&c.timers.push(f)&&!W)W=setInterval(c.fx.tick,13)},show:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.show=true;this.custom(this.prop==="width"||this.prop==="height"?1:0,this.cur());c(this.elem).show()},hide:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.hide=true;this.custom(this.cur(),0)},step:function(a){var b=J(),d=true;if(a||b>=this.options.duration+this.startTime){this.now= -this.end;this.pos=this.state=1;this.update();this.options.curAnim[this.prop]=true;for(var f in this.options.curAnim)if(this.options.curAnim[f]!==true)d=false;if(d){if(this.options.display!=null){this.elem.style.overflow=this.options.overflow;a=c.data(this.elem,"olddisplay");this.elem.style.display=a?a:this.options.display;if(c.css(this.elem,"display")==="none")this.elem.style.display="block"}this.options.hide&&c(this.elem).hide();if(this.options.hide||this.options.show)for(var e in this.options.curAnim)c.style(this.elem, -e,this.options.orig[e]);this.options.complete.call(this.elem)}return false}else{e=b-this.startTime;this.state=e/this.options.duration;a=this.options.easing||(c.easing.swing?"swing":"linear");this.pos=c.easing[this.options.specialEasing&&this.options.specialEasing[this.prop]||a](this.state,e,0,1,this.options.duration);this.now=this.start+(this.end-this.start)*this.pos;this.update()}return true}};c.extend(c.fx,{tick:function(){for(var a=c.timers,b=0;b
"; -a.insertBefore(b,a.firstChild);d=b.firstChild;f=d.firstChild;e=d.nextSibling.firstChild.firstChild;this.doesNotAddBorder=f.offsetTop!==5;this.doesAddBorderForTableAndCells=e.offsetTop===5;f.style.position="fixed";f.style.top="20px";this.supportsFixedPosition=f.offsetTop===20||f.offsetTop===15;f.style.position=f.style.top="";d.style.overflow="hidden";d.style.position="relative";this.subtractsBorderForOverflowNotVisible=f.offsetTop===-5;this.doesNotIncludeMarginInBodyOffset=a.offsetTop!==j;a.removeChild(b); -c.offset.initialize=c.noop},bodyOffset:function(a){var b=a.offsetTop,d=a.offsetLeft;c.offset.initialize();if(c.offset.doesNotIncludeMarginInBodyOffset){b+=parseFloat(c.curCSS(a,"marginTop",true))||0;d+=parseFloat(c.curCSS(a,"marginLeft",true))||0}return{top:b,left:d}},setOffset:function(a,b,d){if(/static/.test(c.curCSS(a,"position")))a.style.position="relative";var f=c(a),e=f.offset(),j=parseInt(c.curCSS(a,"top",true),10)||0,i=parseInt(c.curCSS(a,"left",true),10)||0;if(c.isFunction(b))b=b.call(a, -d,e);d={top:b.top-e.top+j,left:b.left-e.left+i};"using"in b?b.using.call(a,d):f.css(d)}};c.fn.extend({position:function(){if(!this[0])return null;var a=this[0],b=this.offsetParent(),d=this.offset(),f=/^body|html$/i.test(b[0].nodeName)?{top:0,left:0}:b.offset();d.top-=parseFloat(c.curCSS(a,"marginTop",true))||0;d.left-=parseFloat(c.curCSS(a,"marginLeft",true))||0;f.top+=parseFloat(c.curCSS(b[0],"borderTopWidth",true))||0;f.left+=parseFloat(c.curCSS(b[0],"borderLeftWidth",true))||0;return{top:d.top- -f.top,left:d.left-f.left}},offsetParent:function(){return this.map(function(){for(var a=this.offsetParent||s.body;a&&!/^body|html$/i.test(a.nodeName)&&c.css(a,"position")==="static";)a=a.offsetParent;return a})}});c.each(["Left","Top"],function(a,b){var d="scroll"+b;c.fn[d]=function(f){var e=this[0],j;if(!e)return null;if(f!==w)return this.each(function(){if(j=wa(this))j.scrollTo(!a?f:c(j).scrollLeft(),a?f:c(j).scrollTop());else this[d]=f});else return(j=wa(e))?"pageXOffset"in j?j[a?"pageYOffset": -"pageXOffset"]:c.support.boxModel&&j.document.documentElement[d]||j.document.body[d]:e[d]}});c.each(["Height","Width"],function(a,b){var d=b.toLowerCase();c.fn["inner"+b]=function(){return this[0]?c.css(this[0],d,false,"padding"):null};c.fn["outer"+b]=function(f){return this[0]?c.css(this[0],d,false,f?"margin":"border"):null};c.fn[d]=function(f){var e=this[0];if(!e)return f==null?null:this;if(c.isFunction(f))return this.each(function(j){var i=c(this);i[d](f.call(this,j,i[d]()))});return"scrollTo"in -e&&e.document?e.document.compatMode==="CSS1Compat"&&e.document.documentElement["client"+b]||e.document.body["client"+b]:e.nodeType===9?Math.max(e.documentElement["client"+b],e.body["scroll"+b],e.documentElement["scroll"+b],e.body["offset"+b],e.documentElement["offset"+b]):f===w?c.css(e,d):this.css(d,typeof f==="string"?f:f+"px")}});A.jQuery=A.$=c})(window); From fd628ce273f86636d7d7d2055d52de442c69cd18 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sun, 24 Mar 2013 19:16:45 +1100 Subject: [PATCH 12/31] Enabled preview mode when sample is set to 0 Preview mode should run irregardless of the sample size. This is what you would expect to see in a preview mode. --- lib/cohorts.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 818876d..809e6e6 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -6,8 +6,8 @@ Cohorts = (function () { var GoogleAnalyticsAdapter = { nameSpace: 'cohorts', trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { - if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event - _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); + if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event + _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); }, onInitialize: function(inTest, testName, cohort, cv_slot, scope) { if(inTest && scope !== 3) { @@ -28,7 +28,7 @@ Cohorts = (function () { name: null, cohorts: null, scope: 1, - cv_slot: null, + cv_slot: null, sample: 1.0, storageAdapter: null }, options); @@ -49,6 +49,7 @@ Cohorts = (function () { run: function () { // Determine whether there is forcing of cohorts via the URL var hash = window.location.hash; + var preview = false; if (hash.indexOf('#') == 0) hash = hash.slice(1, hash.length); var pairs = hash.split('&'); for (var i = 0; i < pairs.length; i++) { @@ -58,6 +59,7 @@ Cohorts = (function () { if (this.options.name == name) { Utils.log('Forcing test ' + name + ' into cohort ' + cohort); this.setCohort(cohort); + preview = true; } } @@ -67,7 +69,7 @@ Cohorts = (function () { if (in_test === null) // haven't seen this user before in_test = Math.random() <= this.options.sample; - if (in_test) { + if (in_test || preview) { this.setCookie('in_test', 1); if (!this.getCohort()) { From 36ac50368baef7acc677167d586da18e76ecd73d Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sun, 4 Aug 2013 09:47:12 +1000 Subject: [PATCH 13/31] Updating cohorts.js Adding loads of new features to the test object: - User agent match - Cookie match - Referrer match - URL match (for A/B URL redirects) - DOM Ready function to ensure code runs as soon as all elements exist in the DOM - Also made some cosmetic changes to the GA code --- lib/cohorts.js | 494 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 477 insertions(+), 17 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 809e6e6..6c8062c 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -1,3 +1,6 @@ +// Declare GA queue before we add events into it +var _gaq = _gaq || []; + Cohorts = (function () { var Options = { debug: false @@ -5,17 +8,17 @@ Cohorts = (function () { var GoogleAnalyticsAdapter = { nameSpace: 'cohorts', - trackEvent: function(category, action, opt_label, opt_value, int_hit, cv_slot, scope) { - if (cv_slot) _gaq.push(['_setCustomVar', cv_slot, action, opt_label, scope]); // Set custom variable before event + setCustomVar: function(varSlot, action, opt_label, scope) { + _gaq.push(['_setCustomVar', varSlot, action, opt_label, 2]); + }, + trackEvent: function(category, action, opt_label, opt_value, int_hit) { _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); }, - onInitialize: function(inTest, testName, cohort, cv_slot, scope) { - if(inTest && scope !== 3) { - this.trackEvent(this.nameSpace, testName, cohort, 0, true, cv_slot, scope); // No need for cookies at page-level scope - } + onInitialize: function(inTest, testName, cohort, varSlot, scope) { + if (inTest && varSlot) this.setCustomVar(varSlot, testName, cohort, scope) }, onEvent: function(testName, cohort, eventName) { - this.trackEvent(this.nameSpace, testName, cohort + ' | ' + eventName, 0, false); + this.trackEvent(this.nameSpace, testName, cohort + ' - ' + eventName, 0, false); } } @@ -28,7 +31,7 @@ Cohorts = (function () { name: null, cohorts: null, scope: 1, - cv_slot: null, + varSlot: null, sample: 1.0, storageAdapter: null }, options); @@ -37,7 +40,7 @@ Cohorts = (function () { if (this.options.name === null) throw ('A name for this test must be specified'); if (this.options.cohorts === null) throw ('Cohorts must be specified for this test'); if (Utils.size(options.cohorts) < 2) throw ('You must specify at least 2 cohorts for a test'); - if (!this.options.cv_slot) this.options.cv_slot = 5; + if (!this.options.varSlot) this.options.varSlot = 5; if (!this.options.storageAdapter) this.options.storageAdapter = GoogleAnalyticsAdapter; this.cohorts = Utils.keys(this.options.cohorts); @@ -70,21 +73,58 @@ Cohorts = (function () { in_test = Math.random() <= this.options.sample; if (in_test || preview) { + Utils.log("Test Object ["+this.options.name+"] run."); + this.setCookie('in_test', 1); + var chosen_cohort; if (!this.getCohort()) { // determine which cohort the user is chosen to be in var partitions = 1.0 / Utils.size(this.options.cohorts); var chosen_partition = Math.floor(Math.random() / partitions); - var chosen_cohort = Utils.keys(this.options.cohorts)[chosen_partition]; + chosen_cohort = Utils.keys(this.options.cohorts)[chosen_partition]; this.setCohort(chosen_cohort); } else { - var chosen_cohort = this.getCohort(); + chosen_cohort = this.getCohort(); + } + this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.varSlot, this.options.scope); + + var chosenCohortObject = this.options.cohorts[chosen_cohort]; + var isExecutable = this.isExecutable(); + // process chosen Cohort directTo(direct to other page) if directTo property is available. + if (chosenCohortObject.redirectTo&&isExecutable) + { + var rd = chosenCohortObject.redirectTo; + if (Utils.isFunction(chosenCohortObject.redirectTo)) + { + rd = rd(); + } + + if (rd&&rd.indexOf(window.location.href)<0) + { + window.location.href = rd; + return; + } } - this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.cv_slot, this.options.scope); // call the onChosen handler, if it exists - if (this.options.cohorts[chosen_cohort].onChosen) this.options.cohorts[chosen_cohort].onChosen(); + // runNow: 0--execute onChosen Immediately, 1--execute onChosen when dom is ready, default is 0 + if (chosenCohortObject.onChosen&&isExecutable) + { + if (this.options.runNow) + { + Utils.log("Test Object ["+this.options.name+"] cohort onChosen ["+chosen_cohort+"] run."); + chosenCohortObject.onChosen(); + } + else + { + var self = this; + Cohorts.domReady(function(){ + Utils.log("Test Object ["+self.options.name+"] cohort onChosen ["+chosen_cohort+"] run."); + chosenCohortObject.onChosen(); + }); + } + } } else { this.setCookie('in_test', 0); } @@ -92,6 +132,181 @@ Cohorts = (function () { event: function (eventName) { if (this.inTest()) this.options.storageAdapter.onEvent(this.options.name, this.getCohort(), eventName); }, + /** + * check if a test object is Executable(onChosen and redirect can be performed) + * this method will check 4 options, urlMatch, uaMatch, cookieMatch, referrerMatch + * all options must be matched. + * @return {Boolean} + */ + isExecutable:function() + { + var urlMatch = this.urlMatch(); + if (!urlMatch) + { + Utils.log("Test Object ["+this.options.name+"] url not matched."); + return false; + } + + var uaMatch = userAgentUtil.match(this.options.uaMatch); + if (!uaMatch) + { + Utils.log("Test Object ["+this.options.name+"] user agent not matched."); + return false; + } + + var cookieMatch = this.cookieMatch(); + if (!cookieMatch) + { + Utils.log("Test Object ["+this.options.name+"] cookie not matched."); + return false; + } + + var referrerMatch = this.referrerMatch(); + if (!referrerMatch) + { + Utils.log("Test Object ["+this.options.name+"] referrer not matched."); + return false; + } + + return true; + }, + /** + * check if current page url matched the test object urlMatch option(url split feature) + * urlMatch is an array of url items + * you can specify it like this: + * urlMatch: [/test1.html/i, /test1.html/i], that means, the test object will run in test1.html or test2.html + * or urlMatch:[ + * { + * regex: /gclid=/i, + * regexType:1 (0--regex will apply for document.location.href, 1--regex will apply for document.location.search) + * } + * ],that means the regex will apply for document.location.search + * @return {Boolean} + */ + urlMatch: function() + { + var urlRegexes = this.options.urlMatch; + var urlMatch = false; + + if (!urlRegexes) return true; + if (urlRegexes.length <=0) return true; + + for (var i= 0,c=urlRegexes.length;i= 0 ? tv : undefined; + this.mac = dav.indexOf("Macintosh") >= 0; + this.ios = /iPhone|iPod|iPad/.test(dua); + this.android = parseFloat(dua.split("Android ")[1]) || undefined; + this.bb = (dua.indexOf("BlackBerry") >= 0 || dua.indexOf("BB10") >=0)?parseFloat(dua.split("Version/")[1]) || undefined:undefined; + + this.chrome = parseFloat(dua.split("Chrome/")[1]) || undefined; + this.safari = dav.indexOf("Safari")>=0 && !this.chrome ? parseFloat(dav.split("Version/")[1]) : undefined; + + if (this.chrome) this.chrome = Math.floor(this.chrome); + if (this.safari) this.safari = Math.floor(this.safari); + if (this.bb) this.bb = Math.floor(this.bb); + + if (!this.webkit) + { + if (dua.indexOf("Opera") >= 0) + { + this.opera = tv >= 9.8 ? parseFloat(dua.split("Version/")[1]) || tv : tv; + this.opera = Math.floor(this.opera); + } + + if (dua.indexOf("Gecko") >= 0 && !this.khtml && !this.webkit) + { + this.mozilla = tv; + } + if (this.mozilla) + { + this.ff = parseFloat(dua.split("Firefox/")[1] || dua.split("Minefield/")[1]) || undefined; + + if (this.ff) this.ff = Math.floor(this.ff); + } + + if (document.all && !this.opera) + { + var isIE = parseFloat(dav.split("MSIE ")[1]) || undefined; + + var mode = document.documentMode; + if (mode && mode != 5 && Math.floor(isIE) != mode) + { + isIE = mode; + } + + this.ie = isIE; + } + } + + if (dua.match(/(iPhone|iPod|iPad)/)) + { + var p = RegExp.$1.replace(/P/, 'p'); + var v = ua.match(/OS ([\d_]+)/) ? RegExp.$1 : "1"; + var os = parseFloat(v.replace(/_/, '.').replace(/_/g, '')); + this[p] = os; + } + }, + /** + * test if current browser's user agent matched the test object uaMatch options + * @param tua test object uaMatch option, it's an array of ua items, + * you can specify it like this: + * uaMatch:["ie", "chrome"], that means test object will run on ie and chrome + * uaMatch:[{ie:[9, 10]}, {chrome:[25, 26]}] that means test object will run on ie9, ie10, chrome25, chrome26 + * support ua names, ie, ff, chrome, safari, opera + * we can also specify it by platforms like ios, android + * support platform names, ios, android, bb + * @return {Boolean}, true -- matched, false -- not matched. + */ + match: function(tua) + { + if (!tua) return true; + if (tua.length <=0) return true; + + var ret = false; + + for (var i= 0,c=tua.length;i Date: Sun, 4 Aug 2013 10:16:31 +1000 Subject: [PATCH 14/31] Delete --- .gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index e43b0f9..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.DS_Store From 7dbb9bb145ee9eb58deed38eb226d754f4a18284 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Thu, 15 Aug 2013 23:00:32 +1000 Subject: [PATCH 15/31] Ensuring visitors excluded from the test are not allocated to the test --- lib/cohorts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 6c8062c..734bbc6 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -69,7 +69,7 @@ Cohorts = (function () { // Determine whether user should be in the test var in_test = this.inTest(); - if (in_test === null) // haven't seen this user before + if (in_test === null && this.isExecutable()) // haven't seen this user before in_test = Math.random() <= this.options.sample; if (in_test || preview) { From 21f36908f208f4d35c0d123b5eec090e34576fa5 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sun, 18 Aug 2013 21:23:12 +1000 Subject: [PATCH 16/31] Update to test logic, datalayer as storage adapter and better comments Lots of small little tweaks here and there. --- lib/cohorts.js | 141 +++++++++++++++++++++++++++++++------------------ 1 file changed, 91 insertions(+), 50 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 734bbc6..2ad31e7 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -1,32 +1,52 @@ -// Declare GA queue before we add events into it -var _gaq = _gaq || []; +var dataLayer = dataLayer||[{}]; Cohorts = (function () { + + // Enable debugging through the console var Options = { - debug: false + debug: true }; - var GoogleAnalyticsAdapter = { - nameSpace: 'cohorts', - setCustomVar: function(varSlot, action, opt_label, scope) { - _gaq.push(['_setCustomVar', varSlot, action, opt_label, 2]); - }, - trackEvent: function(category, action, opt_label, opt_value, int_hit) { - _gaq.push(['_trackEvent', category, action, opt_label, opt_value, int_hit]); - }, + // By default, Cohorts events are pushed onto a data layer (if one is not defined in the test) + var dataLayerAdapter = { + + nameSpace: 'experiments', onInitialize: function(inTest, testName, cohort, varSlot, scope) { - if (inTest && varSlot) this.setCustomVar(varSlot, testName, cohort, scope) + var key = 'testSlot'+varSlot; + var obj = {}; + obj[key] = testName+' '+cohort; + dataLayer.push(obj); + dataLayer.push({ + 'event': 'experiment', + 'nameSpace': this.nameSpace, + 'testName': testName, + 'cohort': cohort, + 'testScope': scope + }); + }, onEvent: function(testName, cohort, eventName) { - this.trackEvent(this.nameSpace, testName, cohort + ' - ' + eventName, 0, false); + dataLayer.push({ + 'event': 'event', + 'eventCategory': this.nameSpace, + 'eventAction': testName, + 'eventLabel': cohort, + 'eventProperty': scope, + 'eventValue': 1, + 'eventNonInt': false + }); + } + } // The main test object var Test = (function () { + var cookiePrefix = '_cohorts'; var constructor = function (options) { + this.options = Utils.extend({ name: null, cohorts: null, @@ -40,19 +60,22 @@ Cohorts = (function () { if (this.options.name === null) throw ('A name for this test must be specified'); if (this.options.cohorts === null) throw ('Cohorts must be specified for this test'); if (Utils.size(options.cohorts) < 2) throw ('You must specify at least 2 cohorts for a test'); - if (!this.options.varSlot) this.options.varSlot = 5; - if (!this.options.storageAdapter) this.options.storageAdapter = GoogleAnalyticsAdapter; + if (!this.options.varSlot) this.options.varSlot = 5; + if (!this.options.storageAdapter) this.options.storageAdapter = dataLayerAdapter; this.cohorts = Utils.keys(this.options.cohorts); this.run(); + }; constructor.prototype = { + run: function () { - // Determine whether there is forcing of cohorts via the URL + + // Determine whether there is forcing of cohorts via the URL and set the cohort var hash = window.location.hash; - var preview = false; + var preview = false; if (hash.indexOf('#') == 0) hash = hash.slice(1, hash.length); var pairs = hash.split('&'); for (var i = 0; i < pairs.length; i++) { @@ -62,36 +85,47 @@ Cohorts = (function () { if (this.options.name == name) { Utils.log('Forcing test ' + name + ' into cohort ' + cohort); this.setCohort(cohort); - preview = true; + preview = true; } - } - // Determine whether user should be in the test + // Determine whether user should be in the test & they're on a test page var in_test = this.inTest(); - if (in_test === null && this.isExecutable()) // haven't seen this user before - in_test = Math.random() <= this.options.sample; + var isExecutable = this.isExecutable(); + + if (in_test === null && !isExecutable){ + return; + } + if (in_test === null && isExecutable) { + in_test = Math.random() <= this.options.sample; + } + // Visitors selected into the test are assigned a cohort or set to view an existing one if (in_test || preview) { - Utils.log("Test Object ["+this.options.name+"] run."); + Utils.log("Test Object ["+this.options.name+"] run."); this.setCookie('in_test', 1); var chosen_cohort; if (!this.getCohort()) { - // determine which cohort the user is chosen to be in + var partitions = 1.0 / Utils.size(this.options.cohorts); var chosen_partition = Math.floor(Math.random() / partitions); chosen_cohort = Utils.keys(this.options.cohorts)[chosen_partition]; this.setCohort(chosen_cohort); + } else { + + // Returning visitors see the same cohort as last time chosen_cohort = this.getCohort(); + } + + // Track visitors in the test this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.varSlot, this.options.scope); + // If using redirectTo handler, redirect now to the URL (unless we're already there) var chosenCohortObject = this.options.cohorts[chosen_cohort]; - var isExecutable = this.isExecutable(); - // process chosen Cohort directTo(direct to other page) if directTo property is available. if (chosenCohortObject.redirectTo&&isExecutable) { var rd = chosenCohortObject.redirectTo; @@ -107,14 +141,14 @@ Cohorts = (function () { } } - // call the onChosen handler, if it exists - // runNow: 0--execute onChosen Immediately, 1--execute onChosen when dom is ready, default is 0 + // Call the onChosen handler, if it exists and run the contents when we want them to run + // runNow: TRUE Immediately, else we'll execute onChosen when the DOM is ready if (chosenCohortObject.onChosen&&isExecutable) { if (this.options.runNow) { Utils.log("Test Object ["+this.options.name+"] cohort onChosen ["+chosen_cohort+"] run."); - chosenCohortObject.onChosen(); + chosenCohortObject.onChosen(); } else { @@ -126,20 +160,27 @@ Cohorts = (function () { } } } else { - this.setCookie('in_test', 0); + + // For people who were excluded due to sampling, let's exclude them now + if (!this.isExecutable()) this.setCookie('in_test', 0); + } + }, + event: function (eventName) { if (this.inTest()) this.options.storageAdapter.onEvent(this.options.name, this.getCohort(), eventName); }, + /** * check if a test object is Executable(onChosen and redirect can be performed) - * this method will check 4 options, urlMatch, uaMatch, cookieMatch, referrerMatch + * this method will check 4 options, urlMatch, uaBlock, cookieMatch, referrerMatch * all options must be matched. * @return {Boolean} */ isExecutable:function() { + var urlMatch = this.urlMatch(); if (!urlMatch) { @@ -147,10 +188,10 @@ Cohorts = (function () { return false; } - var uaMatch = userAgentUtil.match(this.options.uaMatch); - if (!uaMatch) + var uaBlock = userAgentUtil.match(this.options.uaBlock); + if (!uaBlock) { - Utils.log("Test Object ["+this.options.name+"] user agent not matched."); + Utils.log("Test Object ["+this.options.name+"] user agent blocked."); return false; } @@ -174,11 +215,11 @@ Cohorts = (function () { * check if current page url matched the test object urlMatch option(url split feature) * urlMatch is an array of url items * you can specify it like this: - * urlMatch: [/test1.html/i, /test1.html/i], that means, the test object will run in test1.html or test2.html + * urlMatch: [/test1.html/i, /test2.html/i], that means, the test object will run in test1.html or test2.html * or urlMatch:[ * { * regex: /gclid=/i, - * regexType:1 (0--regex will apply for document.location.href, 1--regex will apply for document.location.search) + * regexType:1 (0--regex will apply for document.location.href, 1--regex will apply for document.location.pathname) * } * ],that means the regex will apply for document.location.search * @return {Boolean} @@ -207,11 +248,11 @@ Cohorts = (function () { if (regType == 0) { - urlMatch = reg.test(document.location.pathname); + urlMatch = reg.test(document.location.href); } else { - urlMatch = reg.test(document.location.search); + urlMatch = reg.test(document.location.pathname); } if (urlMatch) break; @@ -220,17 +261,17 @@ Cohorts = (function () { return urlMatch; }, /** - * check if current page cookies value matched the test object cookieMatch option - * cookieMatch is an array of cookie items, you can specify it like this: + * Check if the current page cookie values match the test object's cookieMatch option + * cookieMatch is an array of cookie items, which you can specify like: * cookieMatch: [{c1: "c1value"}, {c2: "c2value"}] - * that means, the test object will run when current cookies.c1="c1value" or cookies.c2="c2value" - * or cookieMatch: [{c1: "c1value", c2: "c2value"}, {c3: "c3value"}] - * that means, the test object will run when current (cookies.c1="c1value" and cookies.c2="c2value") or cookies.c3="c3value" + * That means, the test object will run when current cookies.c1="c1value" or cookies.c2="c2value" + * For AND and OR matching try cookieMatch: [{c1: "c1value", c2: "c2value"}, {c3: "c3value"}] + * The test object will run when current either value return true: (cookies.c1="c1value" and cookies.c2="c2value") or cookies.c3="c3value" * or you can specify cookieMatch as a function, in this case a cookie object contains all cookie values will pass to this function - * you can make some complex statement in this function, then return true means cookies matched, false means not matched. e.g. + * you can make some complex statements in this function, then return TRUE means cookies were matched, FALSE means they weren't. e.g. * cookieMatch: function(cookies) * { - * return cookies.c1 == "c1value"; + * return cookies.c1 == "c1value"; * } * @return {Boolean} */ @@ -274,7 +315,7 @@ Cohorts = (function () { return cookieMatch; }, /** - * check if current page referrer value matched the test object referrerMatch option + * Check if the current page referrer value matches the test object referrerMatch option * referrerMatch can be an array of referrer values or a function, you can specify it like this: * referrerMatch: ["http://www.google.com", "http://www.facebook.com"], that means the test object will run when * document.referrer == "http://www.google.com" or "http://www.facebook.com" @@ -745,11 +786,11 @@ Cohorts = (function () { } }, /** - * test if current browser's user agent matched the test object uaMatch options - * @param tua test object uaMatch option, it's an array of ua items, + * test if current browser's user agent matched the test object uaBlock options + * @param tua test object uaBlock option, it's an array of ua items, * you can specify it like this: - * uaMatch:["ie", "chrome"], that means test object will run on ie and chrome - * uaMatch:[{ie:[9, 10]}, {chrome:[25, 26]}] that means test object will run on ie9, ie10, chrome25, chrome26 + * uaBlock:["ie", "chrome"], that means test object will run on ie and chrome + * uaBlock:[{ie:[9, 10]}, {chrome:[25, 26]}] that means test object will run on ie9, ie10, chrome25, chrome26 * support ua names, ie, ff, chrome, safari, opera * we can also specify it by platforms like ios, android * support platform names, ios, android, bb From 9e11f6ff16a1c65c928318457b05d8eef9433b0f Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sun, 18 Aug 2013 21:39:32 +1000 Subject: [PATCH 17/31] Create cohorts.js --- test/cohorts.js | 850 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 850 insertions(+) create mode 100644 test/cohorts.js diff --git a/test/cohorts.js b/test/cohorts.js new file mode 100644 index 0000000..2ad31e7 --- /dev/null +++ b/test/cohorts.js @@ -0,0 +1,850 @@ +var dataLayer = dataLayer||[{}]; + +Cohorts = (function () { + + // Enable debugging through the console + var Options = { + debug: true + }; + + // By default, Cohorts events are pushed onto a data layer (if one is not defined in the test) + var dataLayerAdapter = { + + nameSpace: 'experiments', + onInitialize: function(inTest, testName, cohort, varSlot, scope) { + var key = 'testSlot'+varSlot; + var obj = {}; + obj[key] = testName+' '+cohort; + dataLayer.push(obj); + dataLayer.push({ + 'event': 'experiment', + 'nameSpace': this.nameSpace, + 'testName': testName, + 'cohort': cohort, + 'testScope': scope + }); + + }, + onEvent: function(testName, cohort, eventName) { + dataLayer.push({ + 'event': 'event', + 'eventCategory': this.nameSpace, + 'eventAction': testName, + 'eventLabel': cohort, + 'eventProperty': scope, + 'eventValue': 1, + 'eventNonInt': false + }); + + } + + } + + // The main test object + var Test = (function () { + + var cookiePrefix = '_cohorts'; + + var constructor = function (options) { + + this.options = Utils.extend({ + name: null, + cohorts: null, + scope: 1, + varSlot: null, + sample: 1.0, + storageAdapter: null + }, options); + + // Check params + if (this.options.name === null) throw ('A name for this test must be specified'); + if (this.options.cohorts === null) throw ('Cohorts must be specified for this test'); + if (Utils.size(options.cohorts) < 2) throw ('You must specify at least 2 cohorts for a test'); + if (!this.options.varSlot) this.options.varSlot = 5; + if (!this.options.storageAdapter) this.options.storageAdapter = dataLayerAdapter; + + this.cohorts = Utils.keys(this.options.cohorts); + + this.run(); + + }; + + constructor.prototype = { + + run: function () { + + // Determine whether there is forcing of cohorts via the URL and set the cohort + var hash = window.location.hash; + var preview = false; + if (hash.indexOf('#') == 0) hash = hash.slice(1, hash.length); + var pairs = hash.split('&'); + for (var i = 0; i < pairs.length; i++) { + var pair = pairs[i].split('='); + var name = pair[0]; + var cohort = pair[1]; + if (this.options.name == name) { + Utils.log('Forcing test ' + name + ' into cohort ' + cohort); + this.setCohort(cohort); + preview = true; + } + } + + // Determine whether user should be in the test & they're on a test page + var in_test = this.inTest(); + var isExecutable = this.isExecutable(); + + if (in_test === null && !isExecutable){ + return; + } + if (in_test === null && isExecutable) { + in_test = Math.random() <= this.options.sample; + } + + // Visitors selected into the test are assigned a cohort or set to view an existing one + if (in_test || preview) { + + Utils.log("Test Object ["+this.options.name+"] run."); + this.setCookie('in_test', 1); + var chosen_cohort; + + if (!this.getCohort()) { + + var partitions = 1.0 / Utils.size(this.options.cohorts); + var chosen_partition = Math.floor(Math.random() / partitions); + chosen_cohort = Utils.keys(this.options.cohorts)[chosen_partition]; + this.setCohort(chosen_cohort); + + } else { + + // Returning visitors see the same cohort as last time + chosen_cohort = this.getCohort(); + + } + + // Track visitors in the test + this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.varSlot, this.options.scope); + + // If using redirectTo handler, redirect now to the URL (unless we're already there) + var chosenCohortObject = this.options.cohorts[chosen_cohort]; + if (chosenCohortObject.redirectTo&&isExecutable) + { + var rd = chosenCohortObject.redirectTo; + if (Utils.isFunction(chosenCohortObject.redirectTo)) + { + rd = rd(); + } + + if (rd&&rd.indexOf(window.location.href)<0) + { + window.location.href = rd; + return; + } + } + + // Call the onChosen handler, if it exists and run the contents when we want them to run + // runNow: TRUE Immediately, else we'll execute onChosen when the DOM is ready + if (chosenCohortObject.onChosen&&isExecutable) + { + if (this.options.runNow) + { + Utils.log("Test Object ["+this.options.name+"] cohort onChosen ["+chosen_cohort+"] run."); + chosenCohortObject.onChosen(); + } + else + { + var self = this; + Cohorts.domReady(function(){ + Utils.log("Test Object ["+self.options.name+"] cohort onChosen ["+chosen_cohort+"] run."); + chosenCohortObject.onChosen(); + }); + } + } + } else { + + // For people who were excluded due to sampling, let's exclude them now + if (!this.isExecutable()) this.setCookie('in_test', 0); + + } + + }, + + event: function (eventName) { + if (this.inTest()) this.options.storageAdapter.onEvent(this.options.name, this.getCohort(), eventName); + }, + + /** + * check if a test object is Executable(onChosen and redirect can be performed) + * this method will check 4 options, urlMatch, uaBlock, cookieMatch, referrerMatch + * all options must be matched. + * @return {Boolean} + */ + isExecutable:function() + { + + var urlMatch = this.urlMatch(); + if (!urlMatch) + { + Utils.log("Test Object ["+this.options.name+"] url not matched."); + return false; + } + + var uaBlock = userAgentUtil.match(this.options.uaBlock); + if (!uaBlock) + { + Utils.log("Test Object ["+this.options.name+"] user agent blocked."); + return false; + } + + var cookieMatch = this.cookieMatch(); + if (!cookieMatch) + { + Utils.log("Test Object ["+this.options.name+"] cookie not matched."); + return false; + } + + var referrerMatch = this.referrerMatch(); + if (!referrerMatch) + { + Utils.log("Test Object ["+this.options.name+"] referrer not matched."); + return false; + } + + return true; + }, + /** + * check if current page url matched the test object urlMatch option(url split feature) + * urlMatch is an array of url items + * you can specify it like this: + * urlMatch: [/test1.html/i, /test2.html/i], that means, the test object will run in test1.html or test2.html + * or urlMatch:[ + * { + * regex: /gclid=/i, + * regexType:1 (0--regex will apply for document.location.href, 1--regex will apply for document.location.pathname) + * } + * ],that means the regex will apply for document.location.search + * @return {Boolean} + */ + urlMatch: function() + { + var urlRegexes = this.options.urlMatch; + var urlMatch = false; + + if (!urlRegexes) return true; + if (urlRegexes.length <=0) return true; + + for (var i= 0,c=urlRegexes.length;i= 0 ? tv : undefined; + this.mac = dav.indexOf("Macintosh") >= 0; + this.ios = /iPhone|iPod|iPad/.test(dua); + this.android = parseFloat(dua.split("Android ")[1]) || undefined; + this.bb = (dua.indexOf("BlackBerry") >= 0 || dua.indexOf("BB10") >=0)?parseFloat(dua.split("Version/")[1]) || undefined:undefined; + + this.chrome = parseFloat(dua.split("Chrome/")[1]) || undefined; + this.safari = dav.indexOf("Safari")>=0 && !this.chrome ? parseFloat(dav.split("Version/")[1]) : undefined; + + if (this.chrome) this.chrome = Math.floor(this.chrome); + if (this.safari) this.safari = Math.floor(this.safari); + if (this.bb) this.bb = Math.floor(this.bb); + + if (!this.webkit) + { + if (dua.indexOf("Opera") >= 0) + { + this.opera = tv >= 9.8 ? parseFloat(dua.split("Version/")[1]) || tv : tv; + this.opera = Math.floor(this.opera); + } + + if (dua.indexOf("Gecko") >= 0 && !this.khtml && !this.webkit) + { + this.mozilla = tv; + } + if (this.mozilla) + { + this.ff = parseFloat(dua.split("Firefox/")[1] || dua.split("Minefield/")[1]) || undefined; + + if (this.ff) this.ff = Math.floor(this.ff); + } + + if (document.all && !this.opera) + { + var isIE = parseFloat(dav.split("MSIE ")[1]) || undefined; + + var mode = document.documentMode; + if (mode && mode != 5 && Math.floor(isIE) != mode) + { + isIE = mode; + } + + this.ie = isIE; + } + } + + if (dua.match(/(iPhone|iPod|iPad)/)) + { + var p = RegExp.$1.replace(/P/, 'p'); + var v = ua.match(/OS ([\d_]+)/) ? RegExp.$1 : "1"; + var os = parseFloat(v.replace(/_/, '.').replace(/_/g, '')); + this[p] = os; + } + }, + /** + * test if current browser's user agent matched the test object uaBlock options + * @param tua test object uaBlock option, it's an array of ua items, + * you can specify it like this: + * uaBlock:["ie", "chrome"], that means test object will run on ie and chrome + * uaBlock:[{ie:[9, 10]}, {chrome:[25, 26]}] that means test object will run on ie9, ie10, chrome25, chrome26 + * support ua names, ie, ff, chrome, safari, opera + * we can also specify it by platforms like ios, android + * support platform names, ios, android, bb + * @return {Boolean}, true -- matched, false -- not matched. + */ + match: function(tua) + { + if (!tua) return true; + if (tua.length <=0) return true; + + var ret = false; + + for (var i= 0,c=tua.length;i Date: Sun, 18 Aug 2013 22:19:21 +1000 Subject: [PATCH 18/31] Fixing user agent blocking --- test/cohorts.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/cohorts.js b/test/cohorts.js index 2ad31e7..21da604 100644 --- a/test/cohorts.js +++ b/test/cohorts.js @@ -189,7 +189,7 @@ Cohorts = (function () { } var uaBlock = userAgentUtil.match(this.options.uaBlock); - if (!uaBlock) + if (uaBlock === false && this.options.uaBlock) { Utils.log("Test Object ["+this.options.name+"] user agent blocked."); return false; @@ -798,10 +798,10 @@ Cohorts = (function () { */ match: function(tua) { - if (!tua) return true; - if (tua.length <=0) return true; + if (!tua) return false; + if (tua.length <=0) return false; - var ret = false; + var ret = true; for (var i= 0,c=tua.length;i Date: Sun, 18 Aug 2013 22:21:15 +1000 Subject: [PATCH 19/31] Update test.html --- test/test.html | 88 +++++++++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/test/test.html b/test/test.html index 24c5098..604cc88 100644 --- a/test/test.html +++ b/test/test.html @@ -6,50 +6,58 @@ - + + + +

Testing

For more information on how this works, check out the Readme.

From 3252da2d2e347db4102050e49fea8124410e16c9 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Sun, 18 Aug 2013 22:23:17 +1000 Subject: [PATCH 20/31] Fixing user agent blocking --- lib/cohorts.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 2ad31e7..21da604 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -189,7 +189,7 @@ Cohorts = (function () { } var uaBlock = userAgentUtil.match(this.options.uaBlock); - if (!uaBlock) + if (uaBlock === false && this.options.uaBlock) { Utils.log("Test Object ["+this.options.name+"] user agent blocked."); return false; @@ -798,10 +798,10 @@ Cohorts = (function () { */ match: function(tua) { - if (!tua) return true; - if (tua.length <=0) return true; + if (!tua) return false; + if (tua.length <=0) return false; - var ret = false; + var ret = true; for (var i= 0,c=tua.length;i Date: Mon, 19 Aug 2013 18:12:41 +1000 Subject: [PATCH 21/31] Update README.textile --- README.textile | 61 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/README.textile b/README.textile index 7d9b772..2183455 100644 --- a/README.textile +++ b/README.textile @@ -19,30 +19,43 @@ h2. Basic Usage For example, say you want to run a test to determine whether a bigger header link results in more clicks. In your page, you have both big and small header links, and they are not displayed by default. You can setup 2 cohorts like this:

-// Note that this example assumes jQuery
-$(document).ready(function() {
-	var a_test = new Cohorts.Test({
-		name: 'a_test',
-		scope: 1, // Sets the scope for the test and custom variable: 1: Visitor, 2: Session, 3: Page 
-		cv_slot: 5, // Sets the custom variable slot used in the GoogleAnalyticsAdapter
-		sample: 1,
-		cohorts: {
-			variation_a: {
-				onChosen: function() {
-					$('p.message').html('Hello.');
-				}
-			},
-			variation_b: {
-				onChosen: function() {
-					$('p.message').html('Sup?');
-				}
-			},
-		},
-	});
-	$('p.message').click(function() {
-		a_test.event('Clicked on message'); // Track any evens with your storage adapter
-	});
-	// Add more test objects as you need
+var a_test = new Cohorts.Test({
+    name:'CTA Wave 2',
+    scope:1,
+    varSlot:3,
+    sample:1,
+    urlMatch:[
+        {
+            regex: /.*/i // Run on all pages matching a regular expression of the href (optional)
+        }
+    ],
+    uaBlock:[
+        {
+    	    "ie":[5,6,7,8], // Block troublesome browsers (optional)
+        }
+    ],
+    cookieMatch:[
+        {
+        "cookieName": "value" // Run test only when this cookie value exists (optional)
+        }
+    ],
+    referrerMatch:[
+        "https://www.google.com/", "http://www.bing.com/" // Run test only when the referrer matches (optional)
+    ],
+    cohorts:{
+        "Original":{
+            onChosen: function()
+            {
+                // Do nothing
+            }
+        },
+        "Variation A":{
+            onChosen: function()
+            {
+                $('p.message').html('Yo, test is running!'); // When variation runs, do this 
+            }
+        }
+    }
 });
 
From a169a0edec7a45f3313d37080184079ac63f33d4 Mon Sep 17 00:00:00 2001 From: Conor Clafferty Date: Sun, 25 Aug 2013 16:36:38 +0100 Subject: [PATCH 22/31] Fixes referrer being matched when passed an empty array The referrer array is queried even when it's empty. Other filters ignore empty arrays, so this fix is more for consistency. --- lib/cohorts.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/cohorts.js b/lib/cohorts.js index 21da604..d6a31ed 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -329,6 +329,7 @@ Cohorts = (function () { { var tr = this.options.referrerMatch; if (!tr) return true; + if (tr.length <=0) return true; if (Utils.isFunction(tr)) { From 56583b735fb39edb4e7c044bda6bc95dc603f638 Mon Sep 17 00:00:00 2001 From: Dustin Mihalik Date: Mon, 30 Sep 2013 10:23:58 -0500 Subject: [PATCH 23/31] Fixing typo in UA detection for ios devices --- lib/cohorts.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index d6a31ed..d1242ac 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -39,7 +39,7 @@ Cohorts = (function () { } } - + // The main test object var Test = (function () { @@ -781,7 +781,7 @@ Cohorts = (function () { if (dua.match(/(iPhone|iPod|iPad)/)) { var p = RegExp.$1.replace(/P/, 'p'); - var v = ua.match(/OS ([\d_]+)/) ? RegExp.$1 : "1"; + var v = dua.match(/OS ([\d_]+)/) ? RegExp.$1 : "1"; var os = parseFloat(v.replace(/_/, '.').replace(/_/g, '')); this[p] = os; } From 843d27a49c41947660bb7c848390215626f5bf87 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Tue, 8 Oct 2013 14:42:32 +1100 Subject: [PATCH 24/31] Updating object code Had an unnecessary comma in there after the User Agent blocking --- README.textile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.textile b/README.textile index 2183455..91f1ce6 100644 --- a/README.textile +++ b/README.textile @@ -31,7 +31,7 @@ var a_test = new Cohorts.Test({ ], uaBlock:[ { - "ie":[5,6,7,8], // Block troublesome browsers (optional) + "ie":[5,6,7,8] // Block troublesome browsers (optional) } ], cookieMatch:[ From f6cc65077c7d9b1e95fa8ef62e6487e0533e7478 Mon Sep 17 00:00:00 2001 From: David Lee Date: Sun, 13 Oct 2013 14:23:31 +0800 Subject: [PATCH 25/31] support percentage based random cohort choosen --- lib/cohorts.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index d1242ac..f2d447e 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -108,17 +108,13 @@ Cohorts = (function () { var chosen_cohort; if (!this.getCohort()) { - - var partitions = 1.0 / Utils.size(this.options.cohorts); - var chosen_partition = Math.floor(Math.random() / partitions); - chosen_cohort = Utils.keys(this.options.cohorts)[chosen_partition]; + chosen_cohort = this.chooseCohort(); this.setCohort(chosen_cohort); } else { // Returning visitors see the same cohort as last time chosen_cohort = this.getCohort(); - } // Track visitors in the test @@ -167,11 +163,9 @@ Cohorts = (function () { } }, - event: function (eventName) { if (this.inTest()) this.options.storageAdapter.onEvent(this.options.name, this.getCohort(), eventName); }, - /** * check if a test object is Executable(onChosen and redirect can be performed) * this method will check 4 options, urlMatch, uaBlock, cookieMatch, referrerMatch @@ -349,6 +343,84 @@ Cohorts = (function () { return referrerMatch; }, + /** + * choose cohort by random or percentage based random + * if all cohort objects has 'sample' property then use percentage based random + * @returns cohort key + */ + chooseCohort:function() + { + var cohorts = this.options.cohorts; + var chosen_cohort; + + // check if sample property was set in cohort objects + var samplesWasSet = true; + for (var p in cohorts) + { + if (cohorts[p].sample == null) + { + samplesWasSet = false; + break; + } + } + + // no sample was set, just use pure random + if (!samplesWasSet) + { + var partitions = 1.0 / Utils.size(this.options.cohorts); + var chosen_partition = Math.floor(Math.random() / partitions); + chosen_cohort = Utils.keys(this.options.cohorts)[chosen_partition]; + } + else + { + var getRandom = function() + { + return Math.random(); + }; + + var orderedCohorts = this.getOrderedBySampleCohorts(); + + var weight = 0; + var lastSample = orderedCohorts[orderedCohorts.length-1].sample; + + while(!chosen_cohort) + { + weight = getRandom(); + for (var i=0;i Date: Tue, 3 Dec 2013 14:02:42 +0800 Subject: [PATCH 26/31] Add 'New to this test' feature --- lib/cohorts.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index f2d447e..a0bcb3b 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -11,7 +11,9 @@ Cohorts = (function () { var dataLayerAdapter = { nameSpace: 'experiments', - onInitialize: function(inTest, testName, cohort, varSlot, scope) { + onInitialize: function(inTest, testName, cohort, varSlot, scope, newToThisTest) { + if (!newToThisTest) return; + var key = 'testSlot'+varSlot; var obj = {}; obj[key] = testName+' '+cohort; @@ -38,7 +40,7 @@ Cohorts = (function () { } - } + }; // The main test object var Test = (function () { @@ -92,12 +94,14 @@ Cohorts = (function () { // Determine whether user should be in the test & they're on a test page var in_test = this.inTest(); var isExecutable = this.isExecutable(); + var newToThisTest = false; if (in_test === null && !isExecutable){ return; } if (in_test === null && isExecutable) { in_test = Math.random() <= this.options.sample; + if (in_test) newToThisTest = true; } // Visitors selected into the test are assigned a cohort or set to view an existing one @@ -118,7 +122,7 @@ Cohorts = (function () { } // Track visitors in the test - this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.varSlot, this.options.scope); + this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.varSlot, this.options.scope, newToThisTest); // If using redirectTo handler, redirect now to the URL (unless we're already there) var chosenCohortObject = this.options.cohorts[chosen_cohort]; From b277cb6d9908e707cd15bcabeb9a4aeba22f9c41 Mon Sep 17 00:00:00 2001 From: David Lee Date: Fri, 6 Dec 2013 10:58:02 +0800 Subject: [PATCH 27/31] add excludeBySample feature implement Visitor must not be allowed in the test now or in the future, when Excluded by sample parameter. --- lib/cohorts.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index a0bcb3b..7917114 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -95,13 +95,21 @@ Cohorts = (function () { var in_test = this.inTest(); var isExecutable = this.isExecutable(); var newToThisTest = false; + var excludeBySample = false; if (in_test === null && !isExecutable){ return; } if (in_test === null && isExecutable) { in_test = Math.random() <= this.options.sample; - if (in_test) newToThisTest = true; + if (in_test) + { + newToThisTest = true; + } + else + { + excludeBySample = true; + } } // Visitors selected into the test are assigned a cohort or set to view an existing one @@ -159,10 +167,10 @@ Cohorts = (function () { }); } } - } else { - + } else + { // For people who were excluded due to sampling, let's exclude them now - if (!this.isExecutable()) this.setCookie('in_test', 0); + if (excludeBySample) this.setCookie('in_test', 0); } From cae3abfbe1bd4b4db1d4f8beab8d19a381a13083 Mon Sep 17 00:00:00 2001 From: Robert Kingston Date: Thu, 12 Dec 2013 21:09:56 +1100 Subject: [PATCH 28/31] Update cohorts.js Updating storage adapter to pass newToTest into datalayer --- lib/cohorts.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 7917114..5ae69e4 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -11,8 +11,7 @@ Cohorts = (function () { var dataLayerAdapter = { nameSpace: 'experiments', - onInitialize: function(inTest, testName, cohort, varSlot, scope, newToThisTest) { - if (!newToThisTest) return; + onInitialize: function(inTest, testName, cohort, varSlot, scope, newToTest) { var key = 'testSlot'+varSlot; var obj = {}; @@ -23,7 +22,8 @@ Cohorts = (function () { 'nameSpace': this.nameSpace, 'testName': testName, 'cohort': cohort, - 'testScope': scope + 'testScope': scope, + 'newToTest': newToTest }); }, From c1749aa18c580747f1cce2e0018b32c76a370f38 Mon Sep 17 00:00:00 2001 From: David Lee Date: Thu, 19 Dec 2013 17:02:14 +0800 Subject: [PATCH 29/31] Cohorts improvements 1. rewirte Cookies object, small and faster 2. remove unused functions(inChort, Util.size) 3. other minor changes to improve the performence --- lib/cohorts.js | 282 ++++++++++++++----------------------------------- 1 file changed, 82 insertions(+), 200 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 5ae69e4..0d7a294 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -57,16 +57,15 @@ Cohorts = (function () { sample: 1.0, storageAdapter: null }, options); + this.cohorts = Utils.keys(this.options.cohorts); // Check params if (this.options.name === null) throw ('A name for this test must be specified'); if (this.options.cohorts === null) throw ('Cohorts must be specified for this test'); - if (Utils.size(options.cohorts) < 2) throw ('You must specify at least 2 cohorts for a test'); + if (this.cohorts.length < 2) throw ('You must specify at least 2 cohorts for a test'); if (!this.options.varSlot) this.options.varSlot = 5; if (!this.options.storageAdapter) this.options.storageAdapter = dataLayerAdapter; - this.cohorts = Utils.keys(this.options.cohorts); - this.run(); }; @@ -117,16 +116,14 @@ Cohorts = (function () { Utils.log("Test Object ["+this.options.name+"] run."); this.setCookie('in_test', 1); - var chosen_cohort; + var chosen_cohort, currentCohort = this.getCohort(); - if (!this.getCohort()) { + if (!currentCohort) { chosen_cohort = this.chooseCohort(); this.setCohort(chosen_cohort); - } else { - // Returning visitors see the same cohort as last time - chosen_cohort = this.getCohort(); + chosen_cohort = currentCohort; } // Track visitors in the test @@ -186,7 +183,6 @@ Cohorts = (function () { */ isExecutable:function() { - var urlMatch = this.urlMatch(); if (!urlMatch) { @@ -194,8 +190,8 @@ Cohorts = (function () { return false; } - var uaBlock = userAgentUtil.match(this.options.uaBlock); - if (uaBlock === false && this.options.uaBlock) + var uaBlock = this.options.uaBlock?userAgentUtil.match(this.options.uaBlock):false; + if (uaBlock) { Utils.log("Test Object ["+this.options.name+"] user agent blocked."); return false; @@ -379,9 +375,9 @@ Cohorts = (function () { // no sample was set, just use pure random if (!samplesWasSet) { - var partitions = 1.0 / Utils.size(this.options.cohorts); + var partitions = 1.0 / this.cohorts.length; var chosen_partition = Math.floor(Math.random() / partitions); - chosen_cohort = Utils.keys(this.options.cohorts)[chosen_partition]; + chosen_cohort = this.cohorts[chosen_partition]; } else { @@ -434,20 +430,8 @@ Cohorts = (function () { return orderedCohorts; }, inTest: function () { - if (this.getCookie('in_test') == 1) { - return true; - } else if (this.getCookie('in_test') == 0) { - return false; - } else { - return null; - } - }, - inCohort: function (cohort) { - if (this.inTest()) { - return this.getCohort() == cohort; - } else { - return false; - } + var in_test = this.getCookie('in_test'); + return in_test==null?null:in_test==1; }, getCohort: function () { if (this.inTest()) { @@ -478,19 +462,13 @@ Cohorts = (function () { var Utils = { extend: function (destination, source) { for (var property in source) - destination[property] = source[property]; + destination[property] = source[property]; return destination; }, - size: function (object) { - var i = 0; - for (var property in object) - i += 1; - return i; - }, keys: function (object) { var results = []; for (var property in object) - results.push(property); + results.push(property); return results; }, log: function (message) { @@ -503,13 +481,13 @@ Cohorts = (function () { } }, isObject: function(it){ - return it !== undefined && - (it === null || typeof it == "object" ); - }, + return it !== undefined && + (it === null || typeof it == "object" ); + }, isFunction: function(it){ var opts = Object.prototype.toString; - return opts.call(it) === "[object Function]"; - }, + return opts.call(it) === "[object Function]"; + }, arrayIndexOf: function(array, item) { if (Array.prototype.indexOf) @@ -534,188 +512,90 @@ Cohorts = (function () { } }; - // Adapted from James Auldridge's jquery.cookies - var Cookies = (function () { - - var resolveOptions, assembleOptionsString, parseCookies, constructor, defaultOptions = { - expiresAt: null, - path: '/', - domain: null, - secure: false - }; - - /** - * resolveOptions - receive an options object and ensure all options are present and valid, replacing with defaults where necessary - * - * @access private - * @static - * @parameter Object options - optional options to start with - * @return Object complete and valid options object - */ - resolveOptions = function (options) { - var returnValue, expireDate; - - if (typeof options !== 'object' || options === null) { - returnValue = defaultOptions; - } else { - returnValue = { - expiresAt: defaultOptions.expiresAt, - path: defaultOptions.path, - domain: defaultOptions.domain, - secure: defaultOptions.secure - }; - - if (typeof options.expiresAt === 'object' && options.expiresAt instanceof Date) { - returnValue.expiresAt = options.expiresAt; - } - - if (typeof options.path === 'string' && options.path !== '') { - returnValue.path = options.path; - } - - if (typeof options.domain === 'string' && options.domain !== '') { - returnValue.domain = options.domain; - } - - if (options.secure === true) { - returnValue.secure = options.secure; - } - } - - return returnValue; - }; - /** - * assembleOptionsString - analyze options and assemble appropriate string for setting a cookie with those options - * - * @access private - * @static - * @parameter options OBJECT - optional options to start with - * @return STRING - complete and valid cookie setting options - */ - assembleOptionsString = function (options) { - return ( - '; expires=' + ((options.expiresAt === null) ? 0 : options.expiresAt.toGMTString()) + '; path=' + options.path + (typeof options.domain === 'string' ? '; domain=' + options.domain : '') + (options.secure === true ? '; secure' : '')); - }; + // Adapted from dojo.cookie module + var Cookies = { /** * parseCookies - retrieve document.cookie string and break it into a hash with values decoded and unserialized * - * @access private + * @access public * @static * @return OBJECT - hash of cookies from document.cookie */ - parseCookies = function () { - var cookies = {}, i, pair, name, value, separated = document.cookie.split(';'), - unparsedValue; - for (i = 0; i < separated.length; i = i + 1) { + parseCookies: function() + { + var cookies = {}, i, pair, name, value, separated = document.cookie.split(';'), c = separated.length; + for (i = 0; i < c; i++) { pair = separated[i].split('='); name = pair[0].replace(/^\s*/, '').replace(/\s*$/, ''); - - try { - value = decodeURIComponent(pair[1]); - } catch (e1) { - value = pair[1]; - } - - if (typeof JSON === 'object' && JSON !== null && typeof JSON.parse === 'function') { - try { - unparsedValue = value; - value = JSON.parse(value); - } catch (e2) { - value = unparsedValue; - } - } - + value = decodeURIComponent(pair[1])||null; cookies[name] = value; } return cookies; - }; - - constructor = function () {}; - - constructor.prototype.parseCookies = parseCookies; - + }, /** - * get - get one, several, or all cookies + * get - get one cookies * * @access public - * @paramater Mixed cookieName - String:name of single cookie; Array:list of multiple cookie names; Void (no param):if you want all cookies - * @return Mixed - Value of cookie as set; Null:if only one cookie is requested and is not found; Object:hash of multiple or all cookies (if multiple or all requested); + * @paramater String cookieName - name of single cookie + * @return String - Value of cookie as set */ - constructor.prototype.get = function (cookieName) { - var returnValue, item, cookies = parseCookies(); - - if (typeof cookieName === 'string') { - returnValue = (typeof cookies[cookieName] !== 'undefined') ? cookies[cookieName] : null; - } else if (typeof cookieName === 'object' && cookieName !== null) { - returnValue = {}; - for (item in cookieName) { - if (typeof cookies[cookieName[item]] !== 'undefined') { - returnValue[cookieName[item]] = cookies[cookieName[item]]; - } else { - returnValue[cookieName[item]] = null; - } - } - } else { - returnValue = cookies; - } - - return returnValue; - }; + get: function(name) + { + var c = document.cookie, ret; + var matches = c.match(new RegExp("(?:^|; )" + this.escapeString(name) + "=([^;]*)")); + ret = matches ? decodeURIComponent(matches[1]) : null; + return ret; + }, /** * set - set or delete a cookie with desired options * * @access public - * @paramater String cookieName - name of cookie to set - * @paramater Mixed value - Any JS value. If not a string, will be JSON encoded; NULL to delete - * @paramater Object options - optional list of cookie options to specify + * @paramater String name - name of cookie to set + * @paramater String value - value of cookie to set. NULL to delete + * @paramater Object props - optional list of cookie options to specify * @return void */ - constructor.prototype.set = function (cookieName, value, options, scope) { - if (typeof options !== 'object' || options === null) { - var expire = new Date(); - - options = { - expiresAt: new Date(expire), - path: '/', - domain: null, - secure: false - }; - - // Expire cookies after 2yrs with visitor level scope - if (scope === 1) { - expire = expire.setTime(expire.getTime() + 3600000 * 24 * 730); - options.expiresAt = new Date(expire); - } - - // Set cookie to expire at session level for session scope - if (scope === 2) { - options.expiresAt = null; - } - - } - - if (typeof value === 'undefined' || value === null) { - value = ''; - options.hoursToLive = -8760; + set: function(name, value, props, scope) + { + props = props || {}; + props.path||(props.path='/'); + var exp = props.expires, d; + if(typeof exp == "number"){ + d = new Date(); + d.setTime(d.getTime() + exp*24*60*60*1000); + exp = props.expires = d; } - else if (typeof value !== 'string') { - if (typeof JSON === 'object' && JSON !== null && typeof JSON.stringify === 'function') { - value = JSON.stringify(value); - } else { - value = value.toString(); - // just set value = value.toString() in this case - //throw new Error('cookies.set() received non-string value and could not serialize.'); - } + else if (scope === 1) { + // Expire cookies after 2yrs with visitor level scope + d = new Date(); + d.setTime(d.getTime() + 3600000 * 24 * 730); + exp = props.expires = d; } - var optionsString = assembleOptionsString(options); + if(exp && exp.toUTCString){ props.expires = exp.toUTCString(); } - document.cookie = cookieName + '=' + encodeURIComponent(value) + optionsString; - }; - - return new constructor(); - })(); + value = encodeURIComponent(value); + var updatedCookie = name + "=" + value, propName; + for(propName in props){ + updatedCookie += "; " + propName; + var propValue = props[propName]; + if(propValue !== true){ updatedCookie += "=" + propValue; } + } + document.cookie = updatedCookie; + }, + /** + * Adds escape sequences for special characters in regular expressions + * @param str + * @returns string + */ + escapeString: function(str) + { + return str.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, function(ch){ + return "\\" + ch; + }); + } + }; // adapted from https://github.com/requirejs/domReady var domReadyUtil = (function(){ @@ -806,6 +686,7 @@ Cohorts = (function () { })(); // user agent util, inspired by dojo.sniff var userAgentUtil = { + initialized:false, /** * detect user agent attributes */ @@ -869,6 +750,8 @@ Cohorts = (function () { var os = parseFloat(v.replace(/_/, '.').replace(/_/g, '')); this[p] = os; } + + this.initialized = true; }, /** * test if current browser's user agent matched the test object uaBlock options @@ -883,6 +766,7 @@ Cohorts = (function () { */ match: function(tua) { + if (!this.initialized) this.init(); if (!tua) return false; if (tua.length <=0) return false; @@ -903,7 +787,7 @@ Cohorts = (function () { { if (this[p] == items[m]) { - ret = false; + ret = true; break; } } @@ -919,12 +803,10 @@ Cohorts = (function () { if (ret) break; } - return ret; + return ret===undefined?false:ret; } }; - userAgentUtil.init(); - return { Test: Test, Cookies: Cookies, From 54741757d50cec282c942aa2f83e063c3192e44d Mon Sep 17 00:00:00 2001 From: David Lee Date: Sun, 9 Mar 2014 09:58:42 +0800 Subject: [PATCH 30/31] Redefining variable names in test object Updated values name -> "name" scope -> "testScope":"user" // Values should include "user", "session" and "hit". Default should be "user" if this variable is not set. varSlot -> "analyticsSlot" sample -> "sampleRate" urlMatch -> "targetURL" uaBlock -> "userAgentExclude" cookieMatch -> "cookieMatch" referrerMatch -> "referrerMatch" cohorts -> "cohorts" URL match object regex -> "expression": /^\/store\/tyres\/.*/i, regexType -> "match":"pathname|href" --- lib/cohorts.js | 97 ++++++++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 0d7a294..5ad8d5a 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -7,13 +7,26 @@ Cohorts = (function () { debug: true }; + // test scope definition + var TEST_SCOPE = { + USER:"user", + SESSION:"session", + HIT:"hit" + }; + + // target URL match type + var TARGET_URL_MATCH = { + HREF:"href", + PATHNAME:"pathname" + }; + // By default, Cohorts events are pushed onto a data layer (if one is not defined in the test) var dataLayerAdapter = { nameSpace: 'experiments', - onInitialize: function(inTest, testName, cohort, varSlot, scope, newToTest) { + onInitialize: function(inTest, testName, cohort, analyticsSlot, testScope, newToTest) { - var key = 'testSlot'+varSlot; + var key = 'testSlot'+analyticsSlot; var obj = {}; obj[key] = testName+' '+cohort; dataLayer.push(obj); @@ -22,7 +35,7 @@ Cohorts = (function () { 'nameSpace': this.nameSpace, 'testName': testName, 'cohort': cohort, - 'testScope': scope, + 'testScope': testScope, 'newToTest': newToTest }); @@ -33,7 +46,7 @@ Cohorts = (function () { 'eventCategory': this.nameSpace, 'eventAction': testName, 'eventLabel': cohort, - 'eventProperty': scope, + 'eventProperty': scope, // this scope variable is undefined now, is this 'onEvent' method is not in use? 'eventValue': 1, 'eventNonInt': false }); @@ -52,9 +65,9 @@ Cohorts = (function () { this.options = Utils.extend({ name: null, cohorts: null, - scope: 1, - varSlot: null, - sample: 1.0, + testScope: TEST_SCOPE.USER, + analyticsSlot: null, + sampleRate: 1.0, storageAdapter: null }, options); this.cohorts = Utils.keys(this.options.cohorts); @@ -63,7 +76,7 @@ Cohorts = (function () { if (this.options.name === null) throw ('A name for this test must be specified'); if (this.options.cohorts === null) throw ('Cohorts must be specified for this test'); if (this.cohorts.length < 2) throw ('You must specify at least 2 cohorts for a test'); - if (!this.options.varSlot) this.options.varSlot = 5; + if (!this.options.analyticsSlot) this.options.analyticsSlot = 5; if (!this.options.storageAdapter) this.options.storageAdapter = dataLayerAdapter; this.run(); @@ -100,7 +113,7 @@ Cohorts = (function () { return; } if (in_test === null && isExecutable) { - in_test = Math.random() <= this.options.sample; + in_test = Math.random() <= this.options.sampleRate; if (in_test) { newToThisTest = true; @@ -127,7 +140,7 @@ Cohorts = (function () { } // Track visitors in the test - this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.varSlot, this.options.scope, newToThisTest); + this.options.storageAdapter.onInitialize(in_test, this.options.name, chosen_cohort, this.options.analyticsSlot, this.options.testScope, newToThisTest); // If using redirectTo handler, redirect now to the URL (unless we're already there) var chosenCohortObject = this.options.cohorts[chosen_cohort]; @@ -177,21 +190,21 @@ Cohorts = (function () { }, /** * check if a test object is Executable(onChosen and redirect can be performed) - * this method will check 4 options, urlMatch, uaBlock, cookieMatch, referrerMatch + * this method will check 4 options, urlMatch, userAgentExclude, cookieMatch, referrerMatch * all options must be matched. * @return {Boolean} */ isExecutable:function() { - var urlMatch = this.urlMatch(); - if (!urlMatch) + var targetURLMatch = this.targetURLMatch(); + if (!targetURLMatch) { - Utils.log("Test Object ["+this.options.name+"] url not matched."); + Utils.log("Test Object ["+this.options.name+"] targetURL not matched."); return false; } - var uaBlock = this.options.uaBlock?userAgentUtil.match(this.options.uaBlock):false; - if (uaBlock) + var userAgentExclude = this.options.userAgentExclude?userAgentUtil.match(this.options.userAgentExclude):false; + if (userAgentExclude) { Utils.log("Test Object ["+this.options.name+"] user agent blocked."); return false; @@ -214,21 +227,21 @@ Cohorts = (function () { return true; }, /** - * check if current page url matched the test object urlMatch option(url split feature) - * urlMatch is an array of url items + * check if current page url matched the test object targetURL option(url split feature) + * targetURL is an array of url items * you can specify it like this: - * urlMatch: [/test1.html/i, /test2.html/i], that means, the test object will run in test1.html or test2.html - * or urlMatch:[ + * targetURL: [/test1.html/i, /test2.html/i], that means, the test object will run in test1.html or test2.html + * or targetURL:[ * { - * regex: /gclid=/i, - * regexType:1 (0--regex will apply for document.location.href, 1--regex will apply for document.location.pathname) + * expression: /gclid=/i, + * match:"pathname" ("href"--expression will apply for document.location.href, "pathname"--expression will apply for document.location.pathname) * } - * ],that means the regex will apply for document.location.search + * ],that means the regex will apply for document.location.pathname * @return {Boolean} */ - urlMatch: function() + targetURLMatch: function() { - var urlRegexes = this.options.urlMatch; + var urlRegexes = this.options.targetURL; var urlMatch = false; if (!urlRegexes) return true; @@ -240,15 +253,15 @@ Cohorts = (function () { if (Utils.isObject(regObj)&&!regObj.test) { - reg = regObj.regex; - regType = regObj.regexType == null?0:regObj.regexType; + reg = regObj.expression; + regType = regObj.match == null?TARGET_URL_MATCH.HREF:regObj.match; } else { reg = regObj; } - if (regType == 0) + if (regType.toLowerCase() == TARGET_URL_MATCH.HREF) { urlMatch = reg.test(document.location.href); } @@ -353,7 +366,7 @@ Cohorts = (function () { }, /** * choose cohort by random or percentage based random - * if all cohort objects has 'sample' property then use percentage based random + * if all cohort objects has 'sampleRate' property then use percentage based random * @returns cohort key */ chooseCohort:function() @@ -365,7 +378,7 @@ Cohorts = (function () { var samplesWasSet = true; for (var p in cohorts) { - if (cohorts[p].sample == null) + if (cohorts[p].sampleRate == null) { samplesWasSet = false; break; @@ -389,14 +402,14 @@ Cohorts = (function () { var orderedCohorts = this.getOrderedBySampleCohorts(); var weight = 0; - var lastSample = orderedCohorts[orderedCohorts.length-1].sample; + var lastSample = orderedCohorts[orderedCohorts.length-1].sampleRate; while(!chosen_cohort) { weight = getRandom(); for (var i=0;i Date: Mon, 19 May 2014 16:34:16 +0800 Subject: [PATCH 31/31] support same simpleRate values --- lib/cohorts.js | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/lib/cohorts.js b/lib/cohorts.js index 5ad8d5a..6a68dbf 100644 --- a/lib/cohorts.js +++ b/lib/cohorts.js @@ -372,7 +372,7 @@ Cohorts = (function () { chooseCohort:function() { var cohorts = this.options.cohorts; - var chosen_cohort; + var chosen_cohort, partitions, chosen_partition, sameCohorts; // check if sample property was set in cohort objects var samplesWasSet = true; @@ -388,8 +388,8 @@ Cohorts = (function () { // no sample was set, just use pure random if (!samplesWasSet) { - var partitions = 1.0 / this.cohorts.length; - var chosen_partition = Math.floor(Math.random() / partitions); + partitions = 1.0 / this.cohorts.length; + chosen_partition = Math.floor(Math.random() / partitions); chosen_cohort = this.cohorts[chosen_partition]; } else @@ -400,6 +400,7 @@ Cohorts = (function () { }; var orderedCohorts = this.getOrderedBySampleCohorts(); + var sameSimpleRateCohorts = this.getSameSimpleRateValueCohorts(orderedCohorts); var weight = 0; var lastSample = orderedCohorts[orderedCohorts.length-1].sampleRate; @@ -412,6 +413,14 @@ Cohorts = (function () { if (weight <= orderedCohorts[i].sampleRate) { chosen_cohort = orderedCohorts[i].key; + sameCohorts = sameSimpleRateCohorts[orderedCohorts[i].sampleRate]; + if (sameCohorts) + { + partitions = 1.0 / sameCohorts.length; + chosen_partition = Math.floor(Math.random() / partitions); + chosen_cohort = sameCohorts[chosen_partition].key; + } + break; } } @@ -419,6 +428,13 @@ Cohorts = (function () { if (!chosen_cohort && (weight - lastSample)<= (1-lastSample)) { chosen_cohort = orderedCohorts[orderedCohorts.length-1].key; + sameCohorts = sameSimpleRateCohorts[orderedCohorts[orderedCohorts.length-1].sampleRate]; + if (sameCohorts) + { + partitions = 1.0 / sameCohorts.length; + chosen_partition = Math.floor(Math.random() / partitions); + chosen_cohort = sameCohorts[chosen_partition].key; + } } } } @@ -442,6 +458,33 @@ Cohorts = (function () { return orderedCohorts; }, + getSameSimpleRateValueCohorts: function(orderedCohorts) + { + var tempCohorts = {}, simpleRate; + for (var i= 0,c=orderedCohorts.length;i