diff --git a/shared/js/background/components/abn-experiments.js b/shared/js/background/components/abn-experiments.js index ddb3f4523..68efd5827 100644 --- a/shared/js/background/components/abn-experiments.js +++ b/shared/js/background/components/abn-experiments.js @@ -88,10 +88,11 @@ export default class AbnExperimentMetrics { markExperimentEnrolled(featureName, subFeatureName, metrics) { const cohort = this.remoteConfig.getCohort(featureName, subFeatureName); if (cohort && !cohort.enrolledAt) { - cohort.enrolledAt = Date.now(); + // We set the enrollment timestamp as the start of the current day in ET, so that all conversion windows align with ET date changes. + cohort.enrolledAt = startOfDayEST(Date.now()); cohort.metrics = (metrics || generateRetentionMetrics()).map((m) => ({ ...m, counter: 0, sent: false })); sendPixelRequest(`experiment_enroll_${subFeatureName}_${cohort.name}`, { - enrollmentDate: new Date(cohort.enrolledAt).toISOString().slice(0, 10), + enrollmentDate: getDateStringEST(cohort.enrolledAt), }); // updated stored cohort metadata this.remoteConfig.setCohort(featureName, subFeatureName, cohort); @@ -120,7 +121,7 @@ export default class AbnExperimentMetrics { if (!cohort?.metrics || !cohort?.enrolledAt) { return; } - const enrollmentDate = new Date(cohort.enrolledAt).toISOString().slice(0, 10); + const enrollmentDate = getDateStringEST(cohort.enrolledAt); const daysSinceEnrollment = Math.floor(((timestamp || Date.now()) - cohort.enrolledAt) / (1000 * 60 * 60 * 24)); // Find metrics for this experiment that match at this point in time. // i.e. we are within the conversion window, and haven't sent the pixel yet. @@ -155,3 +156,30 @@ export default class AbnExperimentMetrics { }); } } + +/** + * Given a timestamp or date, returns a string in ISO (YYYY-MM-DD) format corresponding for the date + * at that instant in Eastern Summer Time (UTC-5). + * @param {number} timestamp + * @returns {string} Date string in ISO (YYYY-MM-DD) format. + */ +export function getDateStringEST(timestamp) { + // toLocaleDateString can return the date in our chosen timezone (in this case ET), however the output is also localized. + // We can use the 'sv' locale to get a date format that matches ISO (YYYY-MM-DD) + return new Date(timestamp).toLocaleDateString('sv', { timeZone: '-05:00' }); +} + +/** + * Calculates the unix timestamp for the start of the current day in Eastern Summer Time (UTC-5) from the provided + * timestamp. + * @param {number} now + * @returns {number} Timestamp for midnight on the current day in EST. + */ +export function startOfDayEST(now = Date.now()) { + const d = new Date(now); + // Before 05:00 UTC, move back one day + if (d.getUTCHours() < 5) { + d.setUTCDate(d.getUTCDate() - 1); + } + return d.setUTCHours(5, 0, 0, 0); +} diff --git a/shared/js/background/metrics.js b/shared/js/background/metrics.js index 37edd75c8..276924737 100644 --- a/shared/js/background/metrics.js +++ b/shared/js/background/metrics.js @@ -16,7 +16,7 @@ export class AppUseMetric { // trigger on construction: happens whenever the service worker is spun up, which should correlate with browser activity. // Note: we don't care about overtriggering here, as the experiment framework will handle deduplication. // This metric will only correlate with when the browser is open, and not necessarily if it is being 'used'. - abnMetrics.remoteConfig.ready.then(() => abnMetrics.onMetricTriggered('app_use')); + abnMetrics.remoteConfig.allLoadingFinished.then(() => setTimeout(() => abnMetrics.onMetricTriggered('app_use'), 5000)); } } diff --git a/unit-test/background/abn-framework.js b/unit-test/background/abn-framework.js index e94f7528e..fa1121a91 100644 --- a/unit-test/background/abn-framework.js +++ b/unit-test/background/abn-framework.js @@ -2,7 +2,7 @@ import { ParamsValidator } from '@duckduckgo/pixel-schema/src/params_validator.m import messageHandlers from '../../shared/js/background/message-handlers'; import RemoteConfig, { choseCohort } from '../../shared/js/background/components/remote-config'; -import AbnExperimentMetrics from '../../shared/js/background/components/abn-experiments'; +import AbnExperimentMetrics, { getDateStringEST, startOfDayEST } from '../../shared/js/background/components/abn-experiments'; import load from '../../shared/js/background/load'; import commonParams from '../../pixel-definitions/common_params.json'; import commonSuffixes from '../../pixel-definitions/common_suffixes.json'; @@ -351,3 +351,28 @@ describe('ABN pixels', () => { expect(pixelRequests.pop()).toContain('conversionWindowDays=5-7&value=4'); }); }); + +describe('startOfDayEST', () => { + it('always returns a timestamp in the past', () => { + expect(startOfDayEST()).toBeLessThan(Date.now()); + }); + + it('returns a timestamp corresponding to the start of the current day in ET', () => { + const ts = new Date('2025-02-01T06:00:00'); + expect(new Date(startOfDayEST(ts))).toEqual(new Date('Feb 1 2025 00:00:00 UTC-0500')); + ts.setUTCHours(23); + expect(new Date(startOfDayEST(ts))).toEqual(new Date('Feb 1 2025 00:00:00 UTC-0500')); + }); + + it('handles day rollover', () => { + const ts = new Date('2025-02-01T03:00:00'); + expect(new Date(startOfDayEST(ts))).toEqual(new Date('Jan 31 2025 00:00:00 UTC-0500')); + }); +}); + +describe('getDateStringEST', () => { + it('returns a unix timestamp as the date in ET at that instant', () => { + expect(getDateStringEST(new Date('2025-02-01T06:00:00'))).toEqual('2025-02-01'); + expect(getDateStringEST(new Date('2025-02-01T02:00:00'))).toEqual('2025-01-31'); + }); +});