goplsSurvey: remove lumpiness in survey days

Users are occasionally asked to take the gopls survey. The logic
is a little complicated, but in the existing code, if the user is
prompted, it will be for a random day in the rest of the current
month. In the new code all days of the year are pretty nearly equally
probable. The new code chooses a random day in the next 28 days.

There's nothing special about 28, but it's large enough to smooth out
day-of-the-week effects, and small enough that a chosen user
will be prompted for a survey within a month.

Fixes vscode-go/#2545

Change-Id: Ifaccaa5e8e4e8b1a1050768461ed38d4e144561e
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/456041
TryBot-Result: kokoro <noreply+kokoro@google.com>
Run-TryBot: Peter Weinberger <pjw@google.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/src/goSurvey.ts b/src/goSurvey.ts
index a06f1d4..2e9654e 100644
--- a/src/goSurvey.ts
+++ b/src/goSurvey.ts
@@ -7,20 +7,21 @@
 'use strict';
 
 import vscode = require('vscode');
-import { getLocalGoplsVersion } from './language/goLanguageServer';
-import { outputChannel } from './goStatus';
+import { CommandFactory } from './commands';
+import { getGoConfig } from './config';
 import { extensionId } from './const';
-import { getFromGlobalState, getFromWorkspaceState, updateGlobalState } from './stateUtils';
+import { GoExtensionContext } from './context';
 import {
 	developerSurveyConfig,
 	getDeveloperSurveyConfig,
 	maybePromptForDeveloperSurvey,
 	promptForDeveloperSurvey
 } from './goDeveloperSurvey';
-import { getGoConfig } from './config';
+import { outputChannel } from './goStatus';
+import { getLocalGoplsVersion } from './language/goLanguageServer';
+import { getFromGlobalState, getFromWorkspaceState, updateGlobalState } from './stateUtils';
 import { getGoVersion } from './util';
-import { GoExtensionContext } from './context';
-import { CommandFactory } from './commands';
+import { promptNext4Weeks } from './utils/randomDayutils';
 
 // GoplsSurveyConfig is the set of global properties used to determine if
 // we should prompt a user to take the gopls survey.
@@ -35,7 +36,7 @@
 	promptThisMonth?: boolean;
 
 	// dateToPromptThisMonth is the date on which we should prompt the user
-	// this month.
+	// this month. (It is no longer necessarily in the current month.)
 	dateToPromptThisMonth?: Date;
 
 	// dateComputedPromptThisMonth is the date on which the values of
@@ -113,25 +114,20 @@
 	if (cfg.dateComputedPromptThisMonth) {
 		// The extension has been activated this month, so we should have already
 		// decided if the user should be prompted.
-		if (daysBetween(now, cfg.dateComputedPromptThisMonth) < 30) {
+		if (daysBetween(now, cfg.dateComputedPromptThisMonth) < 28) {
 			return cfg;
 		}
 	}
 	// This is the first activation this month (or ever), so decide if we
 	// should prompt the user. This is done by generating a random number in
 	// the range [0, 1) and checking if it is < probability.
-	// We then randomly pick a day in the rest of the month on which to prompt
-	// the user.
+	// We then randomly pick a day in the next 4 weeks to prompt the user.
 	// Probability is set based on the # of responses received, and will be
 	// decreased if we begin receiving > 200 responses/month.
 	const probability = 0.06;
 	cfg.promptThisMonth = Math.random() < probability;
 	if (cfg.promptThisMonth) {
-		// end is the last day of the month, day is the random day of the
-		// month on which to prompt.
-		const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
-		const day = randomIntInRange(now.getUTCDate(), end.getUTCDate());
-		cfg.dateToPromptThisMonth = new Date(now.getFullYear(), now.getMonth(), day);
+		cfg.dateToPromptThisMonth = promptNext4Weeks(now);
 	} else {
 		cfg.dateToPromptThisMonth = undefined;
 	}
@@ -140,13 +136,6 @@
 	return cfg;
 }
 
-// randomIntInRange returns a random integer between min and max, inclusive.
-function randomIntInRange(min: number, max: number): number {
-	const low = Math.ceil(min);
-	const high = Math.floor(max);
-	return Math.floor(Math.random() * (high - low + 1)) + low;
-}
-
 async function promptForGoplsSurvey(
 	goCtx: GoExtensionContext,
 	cfg: GoplsSurveyConfig = {},
diff --git a/src/utils/randomDayutils.ts b/src/utils/randomDayutils.ts
new file mode 100644
index 0000000..a1ac83f
--- /dev/null
+++ b/src/utils/randomDayutils.ts
@@ -0,0 +1,22 @@
+/*---------------------------------------------------------
+ * Copyright 2022 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+
+// find a random day in the next 4 weeks, starting tomorrow
+// (If this code is changed, run the test in calendartest.ts
+// by hand.)
+export function promptNext4Weeks(now: Date): Date {
+	const day = 24 * 3600 * 1000; // milliseconds in a day
+	// choose a random day in the next 4 weeks, starting tomorrow
+	const delta = randomIntInRange(1, 28);
+	const x = now.getTime() + day * delta;
+	return new Date(x);
+}
+
+// randomIntInRange returns a random integer between min and max, inclusive.
+function randomIntInRange(min: number, max: number): number {
+	const low = Math.ceil(min);
+	const high = Math.floor(max);
+	return Math.floor(Math.random() * (high - low + 1)) + low;
+}
diff --git a/test/unit/calendartest.ts b/test/unit/calendartest.ts
new file mode 100644
index 0000000..abaea69
--- /dev/null
+++ b/test/unit/calendartest.ts
@@ -0,0 +1,66 @@
+/*---------------------------------------------------------
+ * Copyright 2022 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+
+import assert from 'assert';
+import { promptNext4Weeks } from '../../src/utils/randomDayutils';
+
+// Set this to run the test. There's no point in running it over
+// and over, but it should be run if promptNext4Weeks is changed.
+const runNextDaysTest = false;
+
+// Test that a year of generated dates looks uniform. This test relies on
+// statistical tests, so in principle, it could fail. The parameters for the
+// statistics are chosen so there should be no more than a 1 in 1,000,000 chance.
+// (Further, if it passes once and the code it correct,
+// it then becomes a test of the random number generator, which is pointless.)
+// (this test takes about 40 msec on a 2018 Macbook Pro)
+suite('random day tests', () => {
+	test('next days', () => {
+		if (!runNextDaysTest) {
+			return;
+		}
+		const newYear = new Date('2024-01-01');
+		const day = 24 * 3600 * 1000;
+		const seen4 = new Array<number>(366);
+		for (let i = 0; i < 366; i++) {
+			seen4[i] = 0;
+		}
+		for (let i = 0; i < 366; i++) {
+			for (let j = 0; j < 100; j++) {
+				const today = new Date(newYear.getTime() + day * i);
+				const next = promptNext4Weeks(today);
+				assert((next.getTime() - today.getTime()) % day === 0);
+				const days = (next.getTime() - today.getTime()) / day;
+				assert(days >= 1 && days <= 28, 'days: ' + days);
+				const doy = Math.floor((next.getTime() - new Date(next.getFullYear(), 0, 0).getTime()) / day);
+				seen4[doy - 1]++;
+			}
+		}
+		assert.ok(isUniform(seen4));
+		// console.log(`elapsed ${new Date().getTime() - now.getTime()} ms}`);
+	});
+});
+
+// decide if the contnts of x look like a uniform distribution, This assumes x.length > 50 or so,
+// and approximates the chi-squared distribution with a normal distribution. (see the Wikipedia article)
+// The approximation is that sqrt(2*chi2) ~ N(sqrt(2*df-1) is good enough for our purposes.
+// The change of getting a 4.8 sigma deviation is about 1 in 1,000,000.
+function isUniform(x: number[], bound = 4.8): boolean {
+	const k = x.length;
+	const df = k - 1;
+	const sum = x.reduce((sum, current) => sum + current, 0);
+	const exp = sum / k;
+	const chi2 = x.reduce((sum, current) => sum + ((current - exp) * (current - exp)) / exp, 0);
+	const sd = Math.sqrt(2 * chi2) - Math.sqrt(2 * df - 1);
+	// sd would be the standard deviation in units of 1. 1
+	let ret = sd < bound && sd > -bound;
+	// and make sure the individual values aren't crazy (5 std devs of normal has prob 3e-7)
+	const expsd = 5 * Math.sqrt(exp);
+	x.map((v) => {
+		if (v < exp - expsd || v > exp + expsd) ret = false;
+	});
+	// console.log(`sd: ${sd} bound: ${bound} expsd: ${expsd} exp: ${exp} chi2: ${chi2} df: ${df}`);
+	return ret;
+}