Skip to content

Commit

Permalink
feat: Add foregroundTimeTracker class
Browse files Browse the repository at this point in the history
  • Loading branch information
rmi22186 committed Feb 6, 2025
1 parent 00dc83a commit dba55e2
Show file tree
Hide file tree
Showing 12 changed files with 277 additions and 0 deletions.
100 changes: 100 additions & 0 deletions src/foregroundTimeTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
export default class ForegroundTimeTracker {
private isTrackerActive: boolean = false;
private localStorageName: string = '';
public startTime: number = 0;
public totalTime: number = 0;

constructor(apiKey: string) {
this.localStorageName = `mp-time-${apiKey}`;
this.loadTimeFromStorage();
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
this.syncAcrossTabs = this.syncAcrossTabs.bind(this);
this.init();
}

private init(): void {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange
);
window.addEventListener('beforeunload', () => this.updateTimeInPersistence());
// Sync time updates across tabs
window.addEventListener('storage', this.syncAcrossTabs);
this.startTracking();

// TODO: this is just to ensure when we load it in an app we can see the timer updates in the console
setInterval(() => {
console.log(this.startTime);
console.log(this.getTimeInForeground());
}, 1000);
}

private loadTimeFromStorage(): void {
const storedTime = localStorage.getItem(this.localStorageName);
if (storedTime !== null) {
this.totalTime = parseFloat(storedTime);
}
}

private startTracking(): void {
if (!document.hidden) {
this.startTime = Math.floor(performance.now());
this.isTrackerActive = true;
}
}

private stopTracking(): void {
if (this.isTrackerActive) {
this.setTotalTime();
this.updateTimeInPersistence();
this.isTrackerActive = false;
}
}

private setTotalTime(): void {
if (this.isTrackerActive) {
const now = Math.floor(performance.now());
this.totalTime += now - this.startTime;
this.startTime = now;
}
}

public updateTimeInPersistence(): void {
if (this.isTrackerActive) {
localStorage.setItem(
this.localStorageName,
this.totalTime.toFixed(0)
);
}
}

private handleVisibilityChange(): void {
if (document.hidden) {
this.stopTracking();
} else {
this.startTracking();
}
}

private syncAcrossTabs(event: StorageEvent): void {
if (event.key === this.localStorageName && event.newValue !== null) {
const newTime = parseFloat(event.newValue) || 0;
// do not overwrite if the new time is smaller than the previous totalTime
if (newTime > this.totalTime) {
this.totalTime = newTime;

}
}
}

public getTimeInForeground(): number {
this.setTotalTime();
this.updateTimeInPersistence();
return this.totalTime;
}

public resetTimer(): void {
this.totalTime = 0;
this.updateTimeInPersistence();
}
}
3 changes: 3 additions & 0 deletions src/mp-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import Identity from './identity';
import Consent, { IConsent } from './consent';
import KitBlocker from './kitBlocking';
import ConfigAPIClient from './configAPIClient';
import ForegroundTimer from './foregroundTimeTracker';
import IdentityAPIClient from './identityApiClient';
import { isFunction, valueof } from './utils';
import { LocalStorageVault } from './vault';
Expand Down Expand Up @@ -66,6 +67,7 @@ export type IntegrationDelays = Dictionary<boolean>;
// https://go.mparticle.com/work/SQDSDKS-6949
export interface IMParticleWebSDKInstance extends MParticleWebSDK {
// Private Properties
_timer: ForegroundTimer;
_APIClient: IAPIClient;
_Consent: IConsent;
_CookieSyncManager: ICookieSyncManager;
Expand Down Expand Up @@ -150,6 +152,7 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan
}
}
this.init = function(apiKey, config) {
self._timer = new ForegroundTimer(apiKey);
if (!config) {
console.warn(
'You did not pass a config object to init(). mParticle will not initialize properly'
Expand Down
2 changes: 2 additions & 0 deletions src/sdkRuntimeModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export interface SDKEvent {
DataPlan?: SDKDataPlan;
LaunchReferral?: string;
ExpandedEventCount: number;
ActiveTimeOnSite: number;
}

export interface SDKGeoLocation {
lat: number | string;
lng: number | string;
Expand Down
1 change: 1 addition & 0 deletions src/sdkToEventsApiConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ export function convertBaseEventData(
custom_attributes: sdkEvent.EventAttributes,
location: convertSDKLocation(sdkEvent.Location),
source_message_id: sdkEvent.SourceMessageId,
active_time_on_site_ms: sdkEvent.ActiveTimeOnSite
};

return commonEventData;
Expand Down
1 change: 1 addition & 0 deletions src/serverModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ export default function ServerModel(
event.data,
event.name
),
ActiveTimeOnSite: mpInstance._timer.getTimeInForeground(),
SourceMessageId:
event.sourceMessageId ||
mpInstance._Helpers.generateUniqueId(),
Expand Down
4 changes: 4 additions & 0 deletions src/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default function SessionManager(
});

mpInstance._Store.nullifySession();
mpInstance._timer.resetTimer();
return;
}

Expand Down Expand Up @@ -180,6 +181,9 @@ export default function SessionManager(
mpInstance._Store.nullifySession();
}
}

mpInstance._timer.resetTimer();

};

this.setSessionTimer = function(): void {
Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const event0: SDKEvent = {
Debug: false,
DeviceId: 'test-device',
Timestamp: 0,
ActiveTimeOnSite: 10
};

export const event1: SDKEvent = {
Expand All @@ -42,6 +43,7 @@ export const event1: SDKEvent = {
Debug: false,
DeviceId: 'test-device',
Timestamp: 0,
ActiveTimeOnSite: 20
};

export const event2: SDKEvent = {
Expand All @@ -64,6 +66,7 @@ export const event2: SDKEvent = {
Debug: false,
DeviceId: 'test-device',
Timestamp: 0,
ActiveTimeOnSite: 30
};

export const event3: SDKEvent = {
Expand All @@ -86,4 +89,5 @@ export const event3: SDKEvent = {
Debug: false,
DeviceId: 'test-device',
Timestamp: 0,
ActiveTimeOnSite: 40
};
152 changes: 152 additions & 0 deletions test/jest/foregroundTimeTracker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import ForegroundTimeTracker from '../../src/foregroundTimeTracker';

describe('ForegroundTimeTracker', () => {
let foregroundTimeTracker: ForegroundTimeTracker;
const apiKey = 'test-key';
const mockStorageKey = `mp-time-${apiKey}`;

beforeEach(() => {
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
jest.useFakeTimers();
localStorage.clear();
});

afterEach(() => {
jest.clearAllTimers();
jest.restoreAllMocks();
});

// in Jest, document.hidden by default is false
it('should initialize with correct localStorage key', () => {
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
expect(foregroundTimeTracker['localStorageName']).toBe(mockStorageKey);
});

it('should load time from localStorage on initialization', () => {
localStorage.setItem(mockStorageKey, '1000');
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
expect(foregroundTimeTracker['totalTime']).toBe(1000);
});

it('should start tracking when the page is visible', () => {
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
jest.spyOn(global.performance, 'now')
.mockReturnValueOnce(1000);
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);

expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
expect(foregroundTimeTracker['startTime']).toBe(1000);
});

it('should not start tracking if the page is hidden', () => {
Object.defineProperty(document, 'hidden', { value: true });
jest.spyOn(global.performance, 'now')
.mockReturnValueOnce(1000);
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);

// since the page is hidden, it does not call performance.now
expect(foregroundTimeTracker['startTime']).toBe(0);
});

it('should stop tracking when the page becomes hidden', () => {
jest.spyOn(global.performance, 'now')
.mockReturnValueOnce(1000)
.mockReturnValueOnce(2000)
.mockReturnValueOnce(3000);

Object.defineProperty(document, 'hidden', { value: false, configurable: true });

foregroundTimeTracker = new ForegroundTimeTracker(apiKey);

expect(foregroundTimeTracker['totalTime']).toBe(0);
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
foregroundTimeTracker['handleVisibilityChange']();
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);

Object.defineProperty(document, 'hidden', { value: true, configurable: true });
foregroundTimeTracker['handleVisibilityChange']();
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
});

it('should resume tracking when the page becomes visible again', () => {
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);

Object.defineProperty(document, 'hidden', { value: true, configurable: true });
foregroundTimeTracker['handleVisibilityChange']();
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);

Object.defineProperty(document, 'hidden', { value: false, configurable: true });
foregroundTimeTracker['handleVisibilityChange']();
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
});

it('should correctly calculate time in foreground', () => {
jest.spyOn(global.performance, 'now')
.mockReturnValueOnce(10)
.mockReturnValueOnce(50)
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);

expect(foregroundTimeTracker.getTimeInForeground()).toBe(40);
});

it('should update time in localStorage and totalTimewhen the page is hidden', () => {
jest.spyOn(global.performance, 'now')
.mockReturnValueOnce(10)
.mockReturnValueOnce(50)
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
foregroundTimeTracker['totalTime'] = 1000;

Object.defineProperty(document, 'hidden', { value: true, configurable: true });
foregroundTimeTracker['handleVisibilityChange']();
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
expect(localStorage.getItem(mockStorageKey)).toBe('1040');
expect(foregroundTimeTracker['totalTime']).toBe(1040);
});

it('should set startTime to the last performance.now call when stopping tracking', () => {
jest.spyOn(global.performance, 'now')
.mockReturnValueOnce(10)
.mockReturnValueOnce(50)
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
foregroundTimeTracker['stopTracking']();
expect(foregroundTimeTracker['startTime']).toBe(50);
});

it('should update totalTime from localStorage when storage event occurs', () => {
localStorage.setItem(mockStorageKey, '7000');
const event = new StorageEvent('storage', {
key: mockStorageKey,
newValue: '7000',
});

foregroundTimeTracker = new ForegroundTimeTracker(apiKey);

foregroundTimeTracker['syncAcrossTabs'](event);
expect(foregroundTimeTracker['totalTime']).toBe(7000);
});

it('should not overwrite totalTime if the new value is smaller', () => {
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);

foregroundTimeTracker['totalTime'] = 8000;
const event = new StorageEvent('storage', {
key: mockStorageKey,
newValue: '3000',
});

foregroundTimeTracker['syncAcrossTabs'](event);
expect(foregroundTimeTracker['totalTime']).toBe(8000);
});

it('should reset totalTime and update localStorage', () => {
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);

foregroundTimeTracker['totalTime'] = 5000;
foregroundTimeTracker.resetTimer();
expect(foregroundTimeTracker['totalTime']).toBe(0);
expect(localStorage.getItem(mockStorageKey)).toBe('0');
});
});
3 changes: 3 additions & 0 deletions test/src/tests-batchUploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe('batch uploader', () => {
Debug: false,
DeviceId: 'test-device',
Timestamp: 0,
ActiveTimeOnSite: 10
};

uploader.queueEvent(event);
Expand Down Expand Up @@ -454,6 +455,7 @@ describe('batch uploader', () => {
Debug: false,
DeviceId: 'test-device',
Timestamp: 0,
ActiveTimeOnSite: 10
};

const expectedEvent = [event];
Expand Down Expand Up @@ -515,6 +517,7 @@ describe('batch uploader', () => {
Debug: false,
DeviceId: 'test-device',
Timestamp: 0,
ActiveTimeOnSite: 10
};

uploader.queueEvent(event);
Expand Down
Loading

0 comments on commit dba55e2

Please sign in to comment.