Skip to content

Commit dba55e2

Browse files
committed
feat: Add foregroundTimeTracker class
1 parent 00dc83a commit dba55e2

12 files changed

+277
-0
lines changed

src/foregroundTimeTracker.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
export default class ForegroundTimeTracker {
2+
private isTrackerActive: boolean = false;
3+
private localStorageName: string = '';
4+
public startTime: number = 0;
5+
public totalTime: number = 0;
6+
7+
constructor(apiKey: string) {
8+
this.localStorageName = `mp-time-${apiKey}`;
9+
this.loadTimeFromStorage();
10+
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
11+
this.syncAcrossTabs = this.syncAcrossTabs.bind(this);
12+
this.init();
13+
}
14+
15+
private init(): void {
16+
document.addEventListener(
17+
'visibilitychange',
18+
this.handleVisibilityChange
19+
);
20+
window.addEventListener('beforeunload', () => this.updateTimeInPersistence());
21+
// Sync time updates across tabs
22+
window.addEventListener('storage', this.syncAcrossTabs);
23+
this.startTracking();
24+
25+
// TODO: this is just to ensure when we load it in an app we can see the timer updates in the console
26+
setInterval(() => {
27+
console.log(this.startTime);
28+
console.log(this.getTimeInForeground());
29+
}, 1000);
30+
}
31+
32+
private loadTimeFromStorage(): void {
33+
const storedTime = localStorage.getItem(this.localStorageName);
34+
if (storedTime !== null) {
35+
this.totalTime = parseFloat(storedTime);
36+
}
37+
}
38+
39+
private startTracking(): void {
40+
if (!document.hidden) {
41+
this.startTime = Math.floor(performance.now());
42+
this.isTrackerActive = true;
43+
}
44+
}
45+
46+
private stopTracking(): void {
47+
if (this.isTrackerActive) {
48+
this.setTotalTime();
49+
this.updateTimeInPersistence();
50+
this.isTrackerActive = false;
51+
}
52+
}
53+
54+
private setTotalTime(): void {
55+
if (this.isTrackerActive) {
56+
const now = Math.floor(performance.now());
57+
this.totalTime += now - this.startTime;
58+
this.startTime = now;
59+
}
60+
}
61+
62+
public updateTimeInPersistence(): void {
63+
if (this.isTrackerActive) {
64+
localStorage.setItem(
65+
this.localStorageName,
66+
this.totalTime.toFixed(0)
67+
);
68+
}
69+
}
70+
71+
private handleVisibilityChange(): void {
72+
if (document.hidden) {
73+
this.stopTracking();
74+
} else {
75+
this.startTracking();
76+
}
77+
}
78+
79+
private syncAcrossTabs(event: StorageEvent): void {
80+
if (event.key === this.localStorageName && event.newValue !== null) {
81+
const newTime = parseFloat(event.newValue) || 0;
82+
// do not overwrite if the new time is smaller than the previous totalTime
83+
if (newTime > this.totalTime) {
84+
this.totalTime = newTime;
85+
86+
}
87+
}
88+
}
89+
90+
public getTimeInForeground(): number {
91+
this.setTotalTime();
92+
this.updateTimeInPersistence();
93+
return this.totalTime;
94+
}
95+
96+
public resetTimer(): void {
97+
this.totalTime = 0;
98+
this.updateTimeInPersistence();
99+
}
100+
}

src/mp-instance.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import Identity from './identity';
3535
import Consent, { IConsent } from './consent';
3636
import KitBlocker from './kitBlocking';
3737
import ConfigAPIClient from './configAPIClient';
38+
import ForegroundTimer from './foregroundTimeTracker';
3839
import IdentityAPIClient from './identityApiClient';
3940
import { isFunction, valueof } from './utils';
4041
import { LocalStorageVault } from './vault';
@@ -66,6 +67,7 @@ export type IntegrationDelays = Dictionary<boolean>;
6667
// https://go.mparticle.com/work/SQDSDKS-6949
6768
export interface IMParticleWebSDKInstance extends MParticleWebSDK {
6869
// Private Properties
70+
_timer: ForegroundTimer;
6971
_APIClient: IAPIClient;
7072
_Consent: IConsent;
7173
_CookieSyncManager: ICookieSyncManager;
@@ -150,6 +152,7 @@ export default function mParticleInstance(this: IMParticleWebSDKInstance, instan
150152
}
151153
}
152154
this.init = function(apiKey, config) {
155+
self._timer = new ForegroundTimer(apiKey);
153156
if (!config) {
154157
console.warn(
155158
'You did not pass a config object to init(). mParticle will not initialize properly'

src/sdkRuntimeModels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ export interface SDKEvent {
7979
DataPlan?: SDKDataPlan;
8080
LaunchReferral?: string;
8181
ExpandedEventCount: number;
82+
ActiveTimeOnSite: number;
8283
}
84+
8385
export interface SDKGeoLocation {
8486
lat: number | string;
8587
lng: number | string;

src/sdkToEventsApiConverter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,7 @@ export function convertBaseEventData(
658658
custom_attributes: sdkEvent.EventAttributes,
659659
location: convertSDKLocation(sdkEvent.Location),
660660
source_message_id: sdkEvent.SourceMessageId,
661+
active_time_on_site_ms: sdkEvent.ActiveTimeOnSite
661662
};
662663

663664
return commonEventData;

src/serverModel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ export default function ServerModel(
345345
event.data,
346346
event.name
347347
),
348+
ActiveTimeOnSite: mpInstance._timer.getTimeInForeground(),
348349
SourceMessageId:
349350
event.sourceMessageId ||
350351
mpInstance._Helpers.generateUniqueId(),

src/sessionManager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export default function SessionManager(
132132
});
133133

134134
mpInstance._Store.nullifySession();
135+
mpInstance._timer.resetTimer();
135136
return;
136137
}
137138

@@ -180,6 +181,9 @@ export default function SessionManager(
180181
mpInstance._Store.nullifySession();
181182
}
182183
}
184+
185+
mpInstance._timer.resetTimer();
186+
183187
};
184188

185189
this.setSessionTimer = function(): void {

test/fixtures/events.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const event0: SDKEvent = {
2020
Debug: false,
2121
DeviceId: 'test-device',
2222
Timestamp: 0,
23+
ActiveTimeOnSite: 10
2324
};
2425

2526
export const event1: SDKEvent = {
@@ -42,6 +43,7 @@ export const event1: SDKEvent = {
4243
Debug: false,
4344
DeviceId: 'test-device',
4445
Timestamp: 0,
46+
ActiveTimeOnSite: 20
4547
};
4648

4749
export const event2: SDKEvent = {
@@ -64,6 +66,7 @@ export const event2: SDKEvent = {
6466
Debug: false,
6567
DeviceId: 'test-device',
6668
Timestamp: 0,
69+
ActiveTimeOnSite: 30
6770
};
6871

6972
export const event3: SDKEvent = {
@@ -86,4 +89,5 @@ export const event3: SDKEvent = {
8689
Debug: false,
8790
DeviceId: 'test-device',
8891
Timestamp: 0,
92+
ActiveTimeOnSite: 40
8993
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import ForegroundTimeTracker from '../../src/foregroundTimeTracker';
2+
3+
describe('ForegroundTimeTracker', () => {
4+
let foregroundTimeTracker: ForegroundTimeTracker;
5+
const apiKey = 'test-key';
6+
const mockStorageKey = `mp-time-${apiKey}`;
7+
8+
beforeEach(() => {
9+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
10+
jest.useFakeTimers();
11+
localStorage.clear();
12+
});
13+
14+
afterEach(() => {
15+
jest.clearAllTimers();
16+
jest.restoreAllMocks();
17+
});
18+
19+
// in Jest, document.hidden by default is false
20+
it('should initialize with correct localStorage key', () => {
21+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
22+
expect(foregroundTimeTracker['localStorageName']).toBe(mockStorageKey);
23+
});
24+
25+
it('should load time from localStorage on initialization', () => {
26+
localStorage.setItem(mockStorageKey, '1000');
27+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
28+
expect(foregroundTimeTracker['totalTime']).toBe(1000);
29+
});
30+
31+
it('should start tracking when the page is visible', () => {
32+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
33+
jest.spyOn(global.performance, 'now')
34+
.mockReturnValueOnce(1000);
35+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
36+
37+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
38+
expect(foregroundTimeTracker['startTime']).toBe(1000);
39+
});
40+
41+
it('should not start tracking if the page is hidden', () => {
42+
Object.defineProperty(document, 'hidden', { value: true });
43+
jest.spyOn(global.performance, 'now')
44+
.mockReturnValueOnce(1000);
45+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
46+
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
47+
48+
// since the page is hidden, it does not call performance.now
49+
expect(foregroundTimeTracker['startTime']).toBe(0);
50+
});
51+
52+
it('should stop tracking when the page becomes hidden', () => {
53+
jest.spyOn(global.performance, 'now')
54+
.mockReturnValueOnce(1000)
55+
.mockReturnValueOnce(2000)
56+
.mockReturnValueOnce(3000);
57+
58+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
59+
60+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
61+
62+
expect(foregroundTimeTracker['totalTime']).toBe(0);
63+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
64+
foregroundTimeTracker['handleVisibilityChange']();
65+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
66+
67+
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
68+
foregroundTimeTracker['handleVisibilityChange']();
69+
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
70+
});
71+
72+
it('should resume tracking when the page becomes visible again', () => {
73+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
74+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
75+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
76+
77+
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
78+
foregroundTimeTracker['handleVisibilityChange']();
79+
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
80+
81+
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
82+
foregroundTimeTracker['handleVisibilityChange']();
83+
expect(foregroundTimeTracker['isTrackerActive']).toBe(true);
84+
});
85+
86+
it('should correctly calculate time in foreground', () => {
87+
jest.spyOn(global.performance, 'now')
88+
.mockReturnValueOnce(10)
89+
.mockReturnValueOnce(50)
90+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
91+
92+
expect(foregroundTimeTracker.getTimeInForeground()).toBe(40);
93+
});
94+
95+
it('should update time in localStorage and totalTimewhen the page is hidden', () => {
96+
jest.spyOn(global.performance, 'now')
97+
.mockReturnValueOnce(10)
98+
.mockReturnValueOnce(50)
99+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
100+
foregroundTimeTracker['totalTime'] = 1000;
101+
102+
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
103+
foregroundTimeTracker['handleVisibilityChange']();
104+
expect(foregroundTimeTracker['isTrackerActive']).toBe(false);
105+
expect(localStorage.getItem(mockStorageKey)).toBe('1040');
106+
expect(foregroundTimeTracker['totalTime']).toBe(1040);
107+
});
108+
109+
it('should set startTime to the last performance.now call when stopping tracking', () => {
110+
jest.spyOn(global.performance, 'now')
111+
.mockReturnValueOnce(10)
112+
.mockReturnValueOnce(50)
113+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
114+
foregroundTimeTracker['stopTracking']();
115+
expect(foregroundTimeTracker['startTime']).toBe(50);
116+
});
117+
118+
it('should update totalTime from localStorage when storage event occurs', () => {
119+
localStorage.setItem(mockStorageKey, '7000');
120+
const event = new StorageEvent('storage', {
121+
key: mockStorageKey,
122+
newValue: '7000',
123+
});
124+
125+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
126+
127+
foregroundTimeTracker['syncAcrossTabs'](event);
128+
expect(foregroundTimeTracker['totalTime']).toBe(7000);
129+
});
130+
131+
it('should not overwrite totalTime if the new value is smaller', () => {
132+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
133+
134+
foregroundTimeTracker['totalTime'] = 8000;
135+
const event = new StorageEvent('storage', {
136+
key: mockStorageKey,
137+
newValue: '3000',
138+
});
139+
140+
foregroundTimeTracker['syncAcrossTabs'](event);
141+
expect(foregroundTimeTracker['totalTime']).toBe(8000);
142+
});
143+
144+
it('should reset totalTime and update localStorage', () => {
145+
foregroundTimeTracker = new ForegroundTimeTracker(apiKey);
146+
147+
foregroundTimeTracker['totalTime'] = 5000;
148+
foregroundTimeTracker.resetTimer();
149+
expect(foregroundTimeTracker['totalTime']).toBe(0);
150+
expect(localStorage.getItem(mockStorageKey)).toBe('0');
151+
});
152+
});

test/src/tests-batchUploader.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ describe('batch uploader', () => {
7676
Debug: false,
7777
DeviceId: 'test-device',
7878
Timestamp: 0,
79+
ActiveTimeOnSite: 10
7980
};
8081

8182
uploader.queueEvent(event);
@@ -454,6 +455,7 @@ describe('batch uploader', () => {
454455
Debug: false,
455456
DeviceId: 'test-device',
456457
Timestamp: 0,
458+
ActiveTimeOnSite: 10
457459
};
458460

459461
const expectedEvent = [event];
@@ -515,6 +517,7 @@ describe('batch uploader', () => {
515517
Debug: false,
516518
DeviceId: 'test-device',
517519
Timestamp: 0,
520+
ActiveTimeOnSite: 10
518521
};
519522

520523
uploader.queueEvent(event);

0 commit comments

Comments
 (0)